Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8ce66702 | ||
|
|
9cd9409089 | ||
|
|
13c70a7a10 | ||
|
|
dd6357e670 | ||
|
|
709a23d08c | ||
|
|
239a2a93f1 | ||
|
|
ea1f058022 | ||
|
|
1fde450ff1 | ||
|
|
c136baa052 | ||
|
|
4f7f6b3281 | ||
|
|
77edb0dea2 | ||
|
|
3676180ae8 | ||
|
|
0e938b66ed | ||
|
|
f0fef26eb7 | ||
|
|
c9bccd4aae | ||
|
|
8ef2e5b91d | ||
|
|
4cab6c09fe | ||
|
|
239fc6d03c | ||
|
|
d085182dc1 | ||
|
|
e949fa3767 | ||
|
|
f1761c91be | ||
|
|
8109f72cf7 | ||
|
|
a0be592d84 | ||
|
|
f4a24b693e | ||
|
|
e4dffb7c18 | ||
|
|
f840843908 | ||
|
|
aacb11b0b9 | ||
|
|
ce6bda18f2 | ||
|
|
aca28405be | ||
|
|
c1eb0659c4 | ||
|
|
b79708fdc7 | ||
|
|
ebe415132e | ||
|
|
f16f206a0a | ||
|
|
a186922e3a | ||
|
|
43513c28f7 | ||
|
|
b6c12bdfcb | ||
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
4cefa5cd88 | ||
|
|
ddf8616716 | ||
|
|
30a694358d | ||
|
|
e01dfa0cd7 | ||
|
|
6c4a2eb626 | ||
|
|
9185b0cce4 | ||
|
|
8925a502ae | ||
|
|
0e4eb1445c | ||
|
|
592d60425f | ||
|
|
a43f343efd | ||
|
|
88ef9d4fa5 | ||
|
|
bda308efd9 | ||
|
|
20718b5a27 | ||
|
|
29db24210c | ||
|
|
a6085eea37 | ||
|
|
e00cc475a2 | ||
|
|
bf32f29acd | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
a45f53071a | ||
|
|
00eb216480 | ||
|
|
8c27024d0e | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
b82a51da80 | ||
|
|
b4753a75cd | ||
|
|
dc67590a96 | ||
|
|
baf4306f51 | ||
|
|
12677a928d |
@@ -109,6 +109,16 @@ steps:
|
|||||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
||||||
# fast no-op once the rebuilt image already ships it).
|
# fast no-op once the rebuilt image already ships it).
|
||||||
- apk add --no-cache openssl
|
- apk add --no-cache openssl
|
||||||
|
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
|
||||||
|
# installed `pi` binary at exactly this measured version — the test
|
||||||
|
# boots Pi's real tool registry to prove the read-only carve-out
|
||||||
|
# resolves to real, unshadowed builtins, and fails loud (by design)
|
||||||
|
# when the runtime is absent or drifts. The canonical Pi is
|
||||||
|
# @earendil-works/[email protected] exactly (@mariozechner/* is
|
||||||
|
# embedded-legacy). Step-level install because ci-base image publishes
|
||||||
|
# are currently blocked on registry auth; fold into Dockerfile.ci once
|
||||||
|
# that is fixed, keeping this as a fast no-op guard.
|
||||||
|
- npm install -g @earendil-works/[email protected]
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
|
|||||||
@@ -11,48 +11,87 @@
|
|||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||||
|
|
||||||
## Package Map
|
### Stack
|
||||||
|
|
||||||
|
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||||
|
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||||
|
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||||
|
- **Authentication:** BetterAuth (`packages/auth`)
|
||||||
|
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||||
|
- **Queue:** Valkey 8 (`packages/queue`)
|
||||||
|
- **Build:** pnpm workspaces and Turborepo
|
||||||
|
- **CI:** Woodpecker CI
|
||||||
|
- **Observability:** OpenTelemetry and Jaeger
|
||||||
|
|
||||||
|
### Package Map
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
| Package | Purpose | Key Dependencies |
|
||||||
| ------------------ | ------------------------------- | -------------------------------- |
|
| ------------------ | ----------------------------- | -------------------------------- |
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
## Architecture Rules
|
## Architecture and Code Conventions
|
||||||
|
|
||||||
1. Gateway is the single API surface — all clients connect through it
|
1. Gateway is the single API surface; all clients connect through it.
|
||||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||||
|
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||||
|
9. Create a task-specific scratchpad for non-trivial work.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # Infrastructure
|
pnpm install --frozen-lockfile
|
||||||
pnpm install # Dependencies
|
pnpm preflight
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|
||||||
|
# Optional local queue service only; do not start the full Compose stack.
|
||||||
|
docker compose up -d valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repo-Specific Notes
|
The pre-push hook requires:
|
||||||
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
```bash
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
- NodeNext module resolution in all tsconfigs
|
```
|
||||||
- Scratchpads are mandatory for non-trivial tasks
|
|
||||||
|
Software delivery also requires the applicable tests. Common repository commands are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # TypeScript checks across the workspace
|
||||||
|
pnpm lint # ESLint across the workspace
|
||||||
|
pnpm test # Checkout tests and package Vitest suites
|
||||||
|
pnpm format:check # Prettier check
|
||||||
|
pnpm build # Build all packages and applications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||||
|
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||||
|
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||||
|
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/db db:generate
|
||||||
|
```
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,5 @@
|
|||||||
# CLAUDE.md — Mosaic Stack
|
# Claude Compatibility Pointer
|
||||||
|
|
||||||
## Project
|
@AGENTS.md
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
|
||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
|
||||||
- **CI**: Woodpecker CI
|
|
||||||
- **Observability**: OpenTelemetry → Jaeger
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck # TypeScript check (all packages)
|
|
||||||
pnpm lint # ESLint (all packages)
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
pnpm test # Vitest (all packages)
|
|
||||||
pnpm build # Build all packages
|
|
||||||
|
|
||||||
# Database
|
|
||||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
|
||||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
|
||||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
|
||||||
|
|
||||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
|
||||||
docker compose up -d valkey
|
|
||||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
|
||||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
|
||||||
- NodeNext module resolution
|
|
||||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
|
||||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
|
||||||
- All three gates must pass before push: typecheck, lint, format:check
|
|
||||||
|
|||||||
@@ -48,9 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 22
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- One or more runtimes:
|
||||||
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
|
- [Codex](https://github.com/openai/codex)
|
||||||
|
- [OpenCode](https://opencode.ai)
|
||||||
|
- [Pi](https://pi.dev)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 22
|
||||||
- pnpm 10.6+
|
- pnpm 10.6+
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||||
import type { RoutingRule } from './routing.types.js';
|
import type { RoutingRule } from './routing.types.js';
|
||||||
@@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js';
|
|||||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[],
|
rules: RoutingRule[],
|
||||||
healthMap: Record<string, { status: string }>,
|
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||||
const allHealthy: Record<string, { status: string }> = {
|
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
anthropic: { status: 'up' },
|
anthropic: { status: 'healthy' },
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
zai: { status: 'up' },
|
zai: { status: 'healthy' },
|
||||||
ollama: { status: 'up' },
|
ollama: { status: 'healthy' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||||
@@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
|||||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||||
const message = 'implement a sort function';
|
const message = 'implement a sort function';
|
||||||
|
|
||||||
const unhealthyHealth = {
|
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
anthropic: { status: 'down' },
|
anthropic: { status: 'down' },
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
zai: { status: 'up' },
|
zai: { status: 'healthy' },
|
||||||
ollama: { status: 'down' },
|
ollama: { status: 'down' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { DB } from '../../database/database.module.js';
|
import { DB } from '../../database/database.module.js';
|
||||||
import { ProviderService } from '../provider.service.js';
|
import { ProviderService } from '../provider.service.js';
|
||||||
import { classifyTask } from './task-classifier.js';
|
import { classifyTask } from './task-classifier.js';
|
||||||
@@ -49,7 +50,7 @@ export class RoutingEngineService {
|
|||||||
async resolve(
|
async resolve(
|
||||||
message: string,
|
message: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
availableProviders?: Record<string, { status: string }>,
|
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
||||||
): Promise<RoutingDecision> {
|
): Promise<RoutingDecision> {
|
||||||
const classification = classifyTask(message);
|
const classification = classifyTask(message);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -69,9 +70,8 @@ export class RoutingEngineService {
|
|||||||
if (!this.matchConditions(rule, classification)) continue;
|
if (!this.matchConditions(rule, classification)) continue;
|
||||||
|
|
||||||
const providerStatus = health[rule.action.provider]?.status;
|
const providerStatus = health[rule.action.provider]?.status;
|
||||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
|
||||||
|
|
||||||
if (!isHealthy) {
|
if (!this.isRoutable(providerStatus)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||||
);
|
);
|
||||||
@@ -111,6 +111,10 @@ export class RoutingEngineService {
|
|||||||
|
|
||||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
||||||
|
return status === 'healthy' || status === 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
private evaluateCondition(
|
private evaluateCondition(
|
||||||
condition: RoutingCondition,
|
condition: RoutingCondition,
|
||||||
classification: TaskClassification,
|
classification: TaskClassification,
|
||||||
@@ -186,11 +190,12 @@ export class RoutingEngineService {
|
|||||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||||
* If none are healthy, return the first entry unconditionally (last resort).
|
* If none are healthy, return the first entry unconditionally (last resort).
|
||||||
*/
|
*/
|
||||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
private applyFallbackChain(
|
||||||
|
health: Record<string, { status: ProviderHealthStatus }>,
|
||||||
|
): RoutingDecision {
|
||||||
for (const candidate of FALLBACK_CHAIN) {
|
for (const candidate of FALLBACK_CHAIN) {
|
||||||
const providerStatus = health[candidate.provider]?.status;
|
const providerStatus = health[candidate.provider]?.status;
|
||||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
if (this.isRoutable(providerStatus)) {
|
||||||
if (isHealthy) {
|
|
||||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||||
return {
|
return {
|
||||||
provider: candidate.provider,
|
provider: candidate.provider,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
|||||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[] = [],
|
rules: RoutingRule[] = [],
|
||||||
healthMap: Record<string, { status: string }> = {},
|
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
const service = makeService(rules, {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
openai: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('high priority');
|
expect(decision.ruleName).toBe('high priority');
|
||||||
@@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
const service = makeService(rules, {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
openai: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('coding rule');
|
expect(decision.ruleName).toBe('coding rule');
|
||||||
@@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // primary is unhealthy
|
anthropic: { status: 'down' }, // primary is unhealthy
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
@@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const service2 = makeService(unhealthyRules, {
|
const service2 = makeService(unhealthyRules, {
|
||||||
anthropic: { status: 'up' },
|
anthropic: { status: 'healthy' },
|
||||||
openai: { status: 'down' },
|
openai: { status: 'down' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||||
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('hello there');
|
const decision = await service.resolve('hello there');
|
||||||
@@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const decision = await service.resolve('completely unrelated message xyz');
|
const decision = await service.resolve('completely unrelated message xyz');
|
||||||
expect(decision.ruleName).toBe('catch-all');
|
expect(decision.ruleName).toBe('catch-all');
|
||||||
@@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const codingDecision = await service.resolve('implement a function');
|
const codingDecision = await service.resolve('implement a function');
|
||||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||||
@@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('enabled fallback');
|
expect(decision.ruleName).toBe('enabled fallback');
|
||||||
@@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
|||||||
ps: unknown,
|
ps: unknown,
|
||||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||||
|
|
||||||
const preSupplied = { anthropic: { status: 'up' } };
|
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
};
|
||||||
await service.resolve('implement a function', undefined, preSupplied);
|
await service.resolve('implement a function', undefined, preSupplied);
|
||||||
|
|
||||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
||||||
|
|
||||||
|
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
||||||
|
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
||||||
|
const codingRule = makeRule({
|
||||||
|
name: 'coding rule',
|
||||||
|
priority: 1,
|
||||||
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
||||||
|
action: { provider: 'openai', model: 'gpt-4o' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// healthy → selected by its own rule, not the fallback chain
|
||||||
|
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
||||||
|
const healthyDecision = await healthyService.resolve('implement a function');
|
||||||
|
expect(healthyDecision.ruleName).toBe('coding rule');
|
||||||
|
expect(healthyDecision.provider).toBe('openai');
|
||||||
|
|
||||||
|
// down → rule is skipped as unroutable, falls through to the fallback chain
|
||||||
|
const downService = makeService([codingRule], {
|
||||||
|
openai: { status: 'down' },
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
const downDecision = await downService.resolve('implement a function');
|
||||||
|
expect(downDecision.ruleName).toBe('fallback');
|
||||||
|
expect(downDecision.provider).toBe('anthropic');
|
||||||
|
|
||||||
|
// degraded → still routable, selected by its own rule, not the fallback chain
|
||||||
|
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
||||||
|
const degradedDecision = await degradedService.resolve('implement a function');
|
||||||
|
expect(degradedDecision.ruleName).toBe('coding rule');
|
||||||
|
expect(degradedDecision.provider).toBe('openai');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
interface ComposedModuleGraph {
|
||||||
|
imports: readonly unknown[];
|
||||||
|
federationModule: unknown;
|
||||||
|
bootLogLines: readonly string[];
|
||||||
|
mosaicConfig: MosaicConfig;
|
||||||
|
resolvedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
|
interface ModuleGraphFixture {
|
||||||
|
tempRoot: string;
|
||||||
|
anchor: string;
|
||||||
|
homePath: string;
|
||||||
|
cwdPath: string;
|
||||||
|
monorepoRootEnvPath: string;
|
||||||
|
gatewayLocalEnvPath: string;
|
||||||
|
daemonEnvPath: string;
|
||||||
|
monorepoRootConfigPath: string;
|
||||||
|
gatewayLocalConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleGraphFixtureOptions {
|
||||||
|
rootEnvMode?: 'present' | 'absent';
|
||||||
|
rootTier?: StorageTier;
|
||||||
|
rootEnvContents?: string;
|
||||||
|
redactionMarker?: string;
|
||||||
|
gatewayLocalTier?: StorageTier;
|
||||||
|
gatewayLocalEnvContents?: string;
|
||||||
|
daemonEnvContents?: string;
|
||||||
|
inheritedTier?: StorageTier;
|
||||||
|
expectedProcessTier?: string;
|
||||||
|
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||||
|
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||||
|
|
||||||
|
function configJson(tier: StorageTier): string {
|
||||||
|
if (tier === 'local') {
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigModuleProvider {
|
||||||
|
provide: string;
|
||||||
|
useFactory: () => MosaicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||||
|
expect(bootLogLines).toHaveLength(1);
|
||||||
|
const [bootLogLine] = bootLogLines;
|
||||||
|
if (bootLogLine === undefined) {
|
||||||
|
throw new Error('Expected a single boot log line');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bootLogLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBootLogLine(
|
||||||
|
bootLogLines: readonly string[],
|
||||||
|
tier: StorageTier,
|
||||||
|
source: string,
|
||||||
|
): void {
|
||||||
|
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||||
|
|
||||||
|
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||||
|
expect(bootLogLine).toContain(`source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleGraphFromDotenv(
|
||||||
|
options: ModuleGraphFixtureOptions,
|
||||||
|
): Promise<ComposedModuleGraph> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||||
|
const fixture: ModuleGraphFixture = {
|
||||||
|
tempRoot,
|
||||||
|
anchor,
|
||||||
|
homePath,
|
||||||
|
cwdPath,
|
||||||
|
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||||
|
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||||
|
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||||
|
};
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
|
||||||
|
for (const path of Object.values(fixture)) {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||||
|
if (
|
||||||
|
options.rootEnvContents !== undefined ||
|
||||||
|
options.rootTier !== undefined ||
|
||||||
|
options.redactionMarker !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||||
|
throw new Error('Expected rootTier or rootEnvContents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||||
|
const rootFixtureWithMarker = options.redactionMarker
|
||||||
|
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||||
|
: rootFixture;
|
||||||
|
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.daemonEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.gatewayLocalEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||||
|
} else if (options.gatewayLocalTier !== undefined) {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalEnvPath,
|
||||||
|
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||||
|
tempRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||||
|
|
||||||
|
await options.setup?.(fixture);
|
||||||
|
|
||||||
|
if (options.inheritedTier !== undefined) {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
|
||||||
|
if (options.inheritedTier === undefined) {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envModule = await import('./env.js');
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||||
|
options.expectedProcessTier ?? options.rootTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { AppModule } = await import('./app.module.js');
|
||||||
|
const { FederationModule } = await import('./federation/federation.module.js');
|
||||||
|
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(imports)) {
|
||||||
|
throw new Error('AppModule imports metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||||
|
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
throw new Error('ConfigModule providers metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configProvider = providers
|
||||||
|
.filter(isConfigModuleProvider)
|
||||||
|
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||||
|
|
||||||
|
if (!configProvider) {
|
||||||
|
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imports,
|
||||||
|
federationModule: FederationModule,
|
||||||
|
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||||
|
args.map((value: unknown): string => String(value)).join(' '),
|
||||||
|
),
|
||||||
|
mosaicConfig: configProvider.useFactory(),
|
||||||
|
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule federation gating', (): void => {
|
||||||
|
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||||
|
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||||
|
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||||
|
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||||
|
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||||
|
|
||||||
|
expect(envImportIndex).toBeGreaterThan(-1);
|
||||||
|
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||||
|
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores ambient cwd/.env and cwd/../.env files',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '..', '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let gatewayLocalConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let daemonConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||||
|
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||||
|
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('standalone'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||||
|
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
|
expectedProcessTier: 'invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
inheritedTier: 'local',
|
||||||
|
expectedProcessTier: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
inheritedTier: 'standalone',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
expectedProcessTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'monorepo-root .env wins over gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'standalone',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['local', 'standalone'] as const)(
|
||||||
|
'does not register FederationModule for the %s tier',
|
||||||
|
async (tier): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const redactionMarker = 'redaction-fixture-marker';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
redactionMarker,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
}),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('local');
|
||||||
|
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.mosaicConfig.storage).toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -21,11 +21,22 @@ import { AdminModule } from './admin/admin.module.js';
|
|||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
|
import { HarnessModule } from './harness/harness.module.js';
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { loadConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
|
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||||
|
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||||
|
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||||
|
// federation profile "must not start in non-federated dev"). The gateway
|
||||||
|
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||||
|
// configuration is visible here.
|
||||||
|
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -50,10 +61,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
|
HarnessModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -74,13 +74,19 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
|
||||||
function buildService(
|
function buildService(
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
mcpClient: {
|
mcpClient: {
|
||||||
reconnectServer: ReturnType<typeof vi.fn>;
|
reconnectServer: ReturnType<typeof vi.fn>;
|
||||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||||
} | null = null,
|
} = mockMcpClient,
|
||||||
): CommandExecutorService {
|
): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const authorization = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
registry as never,
|
registry as never,
|
||||||
@@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
|||||||
{ agents: {} } as never,
|
{ agents: {} } as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
mockMcpClient as never,
|
||||||
authorizationService as never,
|
authorizationService as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Optional()
|
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||||
@Inject(McpClientService)
|
|
||||||
private readonly mcpClient: McpClientService | null,
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
@@ -548,15 +546,6 @@ export class CommandExecutorService {
|
|||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!this.mcpClient) {
|
|
||||||
return {
|
|
||||||
command: 'mcp',
|
|
||||||
conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'MCP client service is not available.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Unknown command returns descriptive error
|
* - Unknown command returns descriptive error
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CommandsModule } from './commands.module.js';
|
||||||
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -47,6 +49,12 @@ const mockBrain = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
null, // mcpClient (optional)
|
mockMcpClient as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +161,15 @@ describe('CommandRegistryService — integration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CommandsModule — Nest wiring', () => {
|
||||||
|
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
||||||
|
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
||||||
|
expect(imports).toContain(McpClientModule);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('CommandExecutorService — integration', () => {
|
describe('CommandExecutorService — integration', () => {
|
||||||
@@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /mcp status reaches the required McpClientService and never reports it unavailable
|
||||||
|
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
||||||
|
const result = await executor.execute(payload, userScope);
|
||||||
|
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('No MCP servers configured.');
|
||||||
|
expect(result.message).not.toBe('MCP client service is not available.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MosaicConfig } from '@mosaicstack/config';
|
|||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
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 { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
@@ -14,7 +15,12 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
|||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
imports: [
|
||||||
|
GCModule,
|
||||||
|
McpClientModule,
|
||||||
|
forwardRef(() => ReloadModule),
|
||||||
|
forwardRef(() => ChatModule),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from '../env.js';
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(),
|
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CoordModule } from './coord.module.js';
|
||||||
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
describe('CoordModule DI (compiled-metadata boot)', () => {
|
||||||
|
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: (): boolean => true })
|
||||||
|
.compile();
|
||||||
|
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
||||||
|
InteractionCoordinationService,
|
||||||
|
);
|
||||||
|
await moduleRef.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
InteractionCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
|
@Optional()
|
||||||
|
@Inject(HANDOFF_ID_FACTORY)
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
type TierSource =
|
||||||
|
| 'process environment'
|
||||||
|
| 'daemon .env'
|
||||||
|
| 'monorepo-root .env'
|
||||||
|
| 'gateway-local .env'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
type BootSource = TierSource | 'mosaic.config.json';
|
||||||
|
|
||||||
|
export interface GatewayDotenvPaths {
|
||||||
|
daemonEnv: string;
|
||||||
|
monorepoRootEnv: string;
|
||||||
|
gatewayLocalEnv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function resolveGatewayDotenvPaths(
|
||||||
|
anchor: string = here,
|
||||||
|
homeBase: string = homedir(),
|
||||||
|
): GatewayDotenvPaths {
|
||||||
|
return {
|
||||||
|
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||||
|
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||||
|
const gatewayHome = resolve(
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||||
|
);
|
||||||
|
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||||
|
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||||
|
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
if (existsSync(daemonConfig)) {
|
||||||
|
return daemonConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(gatewayLocalConfig)) {
|
||||||
|
return gatewayLocalConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(monorepoRootConfig)) {
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||||
|
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||||
|
anchor,
|
||||||
|
homeBase,
|
||||||
|
);
|
||||||
|
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||||
|
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
let databaseUrlSource: TierSource =
|
||||||
|
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||||
|
|
||||||
|
function loadAnchoredDotenv(
|
||||||
|
path: string,
|
||||||
|
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||||
|
): void {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
config({ path, quiet: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeTier === undefined &&
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||||
|
tierSource === 'default'
|
||||||
|
) {
|
||||||
|
tierSource = sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeDatabaseUrl === undefined &&
|
||||||
|
process.env['DATABASE_URL'] !== undefined &&
|
||||||
|
databaseUrlSource === 'default'
|
||||||
|
) {
|
||||||
|
databaseUrlSource = sourceLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||||
|
// It takes precedence over file-based local-dev configuration.
|
||||||
|
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||||
|
|
||||||
|
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||||
|
// from apps/gateway/.env when present.
|
||||||
|
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||||
|
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||||
|
|
||||||
|
const envOnlyTier = detectFromEnv().tier;
|
||||||
|
const configPath = resolveGatewayConfigPath(anchor);
|
||||||
|
const anchoredConfigExists = existsSync(configPath);
|
||||||
|
const resolvedTier = loadConfig(configPath).tier;
|
||||||
|
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||||
|
const recognizedTierDeterminesTier =
|
||||||
|
(configuredTier === 'federated' ||
|
||||||
|
configuredTier === 'standalone' ||
|
||||||
|
configuredTier === 'local') &&
|
||||||
|
configuredTier === envOnlyTier;
|
||||||
|
|
||||||
|
let source: BootSource;
|
||||||
|
if (anchoredConfigExists) {
|
||||||
|
source = 'mosaic.config.json';
|
||||||
|
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||||
|
source = databaseUrlSource;
|
||||||
|
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||||
|
source = tierSource;
|
||||||
|
} else {
|
||||||
|
source = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGatewayEnv();
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
||||||
|
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
||||||
|
import { HarnessModule } from './harness.module.js';
|
||||||
|
|
||||||
|
// A known-available tuple from the fake adapter's default catalog.
|
||||||
|
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
||||||
|
// A tuple whose provider/model are not in any catalog.
|
||||||
|
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
||||||
|
// A tuple that is known in the catalog but flagged unavailable.
|
||||||
|
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function registryWithFake(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Harness selection HTTP surface', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let repository: HarnessSelectionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWithFake())
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// Real in-memory repository from the module graph — proves the module wired it.
|
||||||
|
repository = moduleRef.get(HarnessSelectionRepository);
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset owner-scoped state between tests via the public API surface.
|
||||||
|
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/chat/preferences/selection')
|
||||||
|
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The returned selection is user-1's (guard-derived scope), not the query's.
|
||||||
|
expect(response.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
||||||
|
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
||||||
|
|
||||||
|
const put = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(next)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
expect(put.body.selection).toEqual(next);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.status).toBe(200);
|
||||||
|
expect(get.body.selection).toEqual(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send({ selection: 'gpt-4o' })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
||||||
|
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
||||||
|
['userId', { ...VALID, userId: 'attacker' }],
|
||||||
|
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
||||||
|
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
||||||
|
['home', { ...VALID, home: '/home/attacker' }],
|
||||||
|
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
||||||
|
])(
|
||||||
|
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
||||||
|
async (_name, body) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(body)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(UNKNOWN)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.code).toBe('selection_invalid');
|
||||||
|
// Echoed back unchanged: no first-row / first-provider substitution.
|
||||||
|
expect(response.body.selection).toEqual(UNKNOWN);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(UNAVAILABLE)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.code).toBe('model_unavailable');
|
||||||
|
expect(response.body.selection).toEqual(UNAVAILABLE);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { HarnessOperationError } from './harness.registry.js';
|
||||||
|
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||||
|
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
||||||
|
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
||||||
|
* query string can never name another user, tenant, or seat. A typed selection
|
||||||
|
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
||||||
|
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
||||||
|
* unchanged, and never mutates the stored selection.
|
||||||
|
*/
|
||||||
|
@Controller('api/chat/preferences/selection')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class HarnessSelectionController {
|
||||||
|
constructor(private readonly selection: HarnessSelectionService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
||||||
|
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async put(
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Body() dto: HarnessSelectionInputDto,
|
||||||
|
): Promise<SelectionResponseDto> {
|
||||||
|
try {
|
||||||
|
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
||||||
|
harnessId: dto.harnessId,
|
||||||
|
providerId: dto.providerId,
|
||||||
|
modelId: dto.modelId,
|
||||||
|
});
|
||||||
|
return { selection: stored };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessOperationError) {
|
||||||
|
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { readContextFromScope } from './harness.dto.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
||||||
|
* requested harness/provider/model tuple against the live catalog with NO
|
||||||
|
* fallback substitution, then persists it owner-scoped. The stored selection is
|
||||||
|
* only ever mutated when the tuple is valid AND available.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessSelectionService {
|
||||||
|
constructor(
|
||||||
|
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||||
|
private readonly repository: HarnessSelectionRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
||||||
|
return this.repository.get(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSelection(
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<HarnessSelection> {
|
||||||
|
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
||||||
|
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
||||||
|
await this.assertSelectionAvailable(scope, selection);
|
||||||
|
return this.repository.set(scope, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
|
||||||
|
let adapter;
|
||||||
|
try {
|
||||||
|
adapter = this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await adapter.catalog(readContextFromScope(scope));
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
// The real module under test — importing it (not a hand-listed controllers/mocks
|
||||||
|
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
||||||
|
import { HarnessModule } from './harness.module.js';
|
||||||
|
|
||||||
|
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
||||||
|
const FORBIDDEN_KEYS = [
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
'env',
|
||||||
|
'secret',
|
||||||
|
'secrets',
|
||||||
|
'token',
|
||||||
|
'apiKey',
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertNoForbiddenLeak(payload: unknown): void {
|
||||||
|
const serialized = JSON.stringify(payload).toLowerCase();
|
||||||
|
for (const key of FORBIDDEN_KEYS) {
|
||||||
|
expect(serialized).not.toContain(key.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function registryWithFake(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Harness catalog HTTP surface', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWithFake())
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
||||||
|
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
||||||
|
// have thrown and this suite would never reach here.
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBeGreaterThan(0);
|
||||||
|
const summary = response.body[0];
|
||||||
|
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
||||||
|
expect(summary.id).toBe('fake');
|
||||||
|
expect(typeof summary.displayName).toBe('string');
|
||||||
|
expect(Array.isArray(summary.capabilities)).toBe(true);
|
||||||
|
assertNoForbiddenLeak(response.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.harnessId).toBe('fake');
|
||||||
|
expect(typeof response.body.version).toBe('string');
|
||||||
|
expect(typeof response.body.fingerprint).toBe('string');
|
||||||
|
expect(Array.isArray(response.body.models)).toBe(true);
|
||||||
|
expect(response.body.models.length).toBeGreaterThan(0);
|
||||||
|
const entry = response.body.models[0];
|
||||||
|
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'authState',
|
||||||
|
'availability',
|
||||||
|
'displayName',
|
||||||
|
'harnessId',
|
||||||
|
'inputTypes',
|
||||||
|
'modelId',
|
||||||
|
'providerId',
|
||||||
|
'reasoningCapability',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
assertNoForbiddenLeak(response.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.code).toBe('adapter_unavailable');
|
||||||
|
// A fallback catalog would carry a models array; a typed error must not.
|
||||||
|
expect(response.body.models).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import {
|
||||||
|
readContextFromScope,
|
||||||
|
toHarnessSummary,
|
||||||
|
toSafeCatalog,
|
||||||
|
type HarnessCatalogDto,
|
||||||
|
type HarnessSummaryDto,
|
||||||
|
} from './harness.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
||||||
|
* fields (identity, capabilities, provider/model catalog) — never executables,
|
||||||
|
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
||||||
|
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
||||||
|
*/
|
||||||
|
@Controller('api/harnesses')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class HarnessController {
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
||||||
|
const context = readContextFromScope(scopeFromUser(user));
|
||||||
|
const summaries: HarnessSummaryDto[] = [];
|
||||||
|
for (const adapter of this.registry.list()) {
|
||||||
|
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
||||||
|
}
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':harnessId/catalog')
|
||||||
|
async catalog(
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Param('harnessId') harnessId: string,
|
||||||
|
): Promise<HarnessCatalogDto> {
|
||||||
|
const context = readContextFromScope(scopeFromUser(user));
|
||||||
|
let adapter;
|
||||||
|
try {
|
||||||
|
adapter = this.registry.get(harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
||||||
|
throw new HttpException(
|
||||||
|
{ code: error.code, message: error.message, harnessId },
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return toSafeCatalog(await adapter.catalog(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessInputType,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
||||||
|
*
|
||||||
|
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
||||||
|
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
||||||
|
* any extra property — including smuggled server-authority fields such as
|
||||||
|
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
||||||
|
* is rejected with 400. There is deliberately no field through which a caller can
|
||||||
|
* name a scope; scope is derived on the server from the authenticated session.
|
||||||
|
*/
|
||||||
|
export class HarnessSelectionInputDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
harnessId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
modelId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe harness summary — identity and capabilities only. */
|
||||||
|
export interface HarnessSummaryDto {
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly capabilities: readonly HarnessCapability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
||||||
|
export interface HarnessCatalogEntryDto {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly reasoningCapability: boolean;
|
||||||
|
readonly inputTypes: readonly HarnessInputType[];
|
||||||
|
readonly authState: HarnessAuthState;
|
||||||
|
readonly availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe catalog envelope. */
|
||||||
|
export interface HarnessCatalogDto {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly models: readonly HarnessCatalogEntryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response envelope for the caller's current selection (null when unset). */
|
||||||
|
export interface SelectionResponseDto {
|
||||||
|
readonly selection: HarnessSelection | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
||||||
|
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
||||||
|
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
||||||
|
*/
|
||||||
|
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.userId,
|
||||||
|
correlationId: randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
||||||
|
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
||||||
|
return {
|
||||||
|
id: descriptor.id,
|
||||||
|
displayName: descriptor.displayName,
|
||||||
|
capabilities: [...descriptor.capabilities],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
||||||
|
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
||||||
|
return {
|
||||||
|
harnessId: catalog.harnessId,
|
||||||
|
version: catalog.version,
|
||||||
|
fingerprint: catalog.fingerprint,
|
||||||
|
models: catalog.models.map(toSafeCatalogEntry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
||||||
|
return {
|
||||||
|
harnessId: entry.harnessId,
|
||||||
|
providerId: entry.providerId,
|
||||||
|
modelId: entry.modelId,
|
||||||
|
displayName: entry.displayName,
|
||||||
|
reasoningCapability: entry.reasoningCapability,
|
||||||
|
inputTypes: [...entry.inputTypes],
|
||||||
|
authState: entry.authState,
|
||||||
|
availability: entry.availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HarnessService } from './harness.service.js';
|
||||||
|
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
||||||
|
import { HarnessController } from './harness.controller.js';
|
||||||
|
import { HarnessSelectionController } from './harness-selection.controller.js';
|
||||||
|
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the harness-neutral registry/service (Task Two) together with the
|
||||||
|
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
||||||
|
*
|
||||||
|
* The registry is provided empty here; real harness adapters are registered in a
|
||||||
|
* later task. Because the controllers/services resolve their collaborators through
|
||||||
|
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [HarnessController, HarnessSelectionController],
|
||||||
|
providers: [
|
||||||
|
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
||||||
|
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
||||||
|
HarnessSelectionRepository,
|
||||||
|
HarnessSelectionService,
|
||||||
|
],
|
||||||
|
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
|
||||||
|
})
|
||||||
|
export class HarnessModule {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistrationError,
|
||||||
|
HarnessRegistry,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
describe('HarnessRegistry', () => {
|
||||||
|
it('registers and looks up an adapter by harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
|
||||||
|
registry.register(adapter);
|
||||||
|
|
||||||
|
expect(registry.get('fake')).toBe(adapter);
|
||||||
|
expect(registry.has('fake')).toBe(true);
|
||||||
|
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a blank adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||||
|
expect(registry.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||||
|
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||||
|
// The original registration is untouched.
|
||||||
|
expect(registry.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.get('missing');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||||
|
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||||
|
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||||
|
expect(registry.has('missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessErrorCode,
|
||||||
|
HarnessErrorDto,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||||
|
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||||
|
* tuple — there is no field through which a substituted "effective" selection
|
||||||
|
* could ever be reported.
|
||||||
|
*/
|
||||||
|
export class HarnessOperationError extends Error {
|
||||||
|
readonly code: HarnessErrorCode;
|
||||||
|
readonly dto: HarnessErrorDto;
|
||||||
|
|
||||||
|
constructor(dto: HarnessErrorDto) {
|
||||||
|
super(dto.message);
|
||||||
|
this.name = 'HarnessOperationError';
|
||||||
|
this.code = dto.code;
|
||||||
|
this.dto = dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||||
|
export function operationError(
|
||||||
|
code: HarnessErrorCode,
|
||||||
|
message: string,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
correlationId: string,
|
||||||
|
retryable = false,
|
||||||
|
): HarnessOperationError {
|
||||||
|
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||||
|
export class HarnessAdapterUnavailableError extends Error {
|
||||||
|
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||||
|
|
||||||
|
constructor(readonly harnessId: string) {
|
||||||
|
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||||
|
this.name = 'HarnessAdapterUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||||
|
|
||||||
|
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||||
|
export class HarnessRegistrationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly reason: HarnessRegistrationFailure,
|
||||||
|
readonly harnessId: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
reason === 'blank_id'
|
||||||
|
? 'A harness adapter id must be a non-empty string.'
|
||||||
|
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||||
|
);
|
||||||
|
this.name = 'HarnessRegistrationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||||
|
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||||
|
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessRegistry {
|
||||||
|
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||||
|
|
||||||
|
register(adapter: HarnessAdapter): void {
|
||||||
|
const id = adapter.id;
|
||||||
|
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||||
|
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||||
|
}
|
||||||
|
if (this.adapters.has(id)) {
|
||||||
|
throw new HarnessRegistrationError('duplicate_id', id);
|
||||||
|
}
|
||||||
|
this.adapters.set(id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(harnessId: string): HarnessAdapter {
|
||||||
|
const adapter = this.adapters.get(harnessId);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new HarnessAdapterUnavailableError(harnessId);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(harnessId: string): boolean {
|
||||||
|
return this.adapters.has(harnessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): readonly HarnessAdapter[] {
|
||||||
|
return [...this.adapters.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
||||||
|
import {
|
||||||
|
HarnessScopeViolationError,
|
||||||
|
HarnessService,
|
||||||
|
type TrustedGatewayScope,
|
||||||
|
} from './harness.service.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
const SCOPE: TrustedGatewayScope = {
|
||||||
|
actorId: 'actor-trusted',
|
||||||
|
tenantId: 'tenant-trusted',
|
||||||
|
seatId: 'seat-trusted',
|
||||||
|
correlationId: 'correlation-trusted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const READ_CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: SCOPE.actorId,
|
||||||
|
tenantId: SCOPE.tenantId,
|
||||||
|
seatId: SCOPE.seatId,
|
||||||
|
correlationId: SCOPE.correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(capabilities?: readonly HarnessCapability[]) {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
||||||
|
registry.register(adapter);
|
||||||
|
const service = new HarnessService(registry);
|
||||||
|
return { registry, adapter, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const entry = catalog.models.find((model) => model.availability === 'available');
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('fixture requires an available model');
|
||||||
|
}
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HarnessService', () => {
|
||||||
|
it('derives the actor context from trusted scope on create', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const snapshot = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
||||||
|
expect(snapshot.state).toBe('idle');
|
||||||
|
expect(snapshot.selection).toEqual(selection);
|
||||||
|
expect(snapshot.nativeSessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects server-authority fields supplied by an external caller', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const hostile = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
seatId: 'attacker-seat',
|
||||||
|
executablePath: '/usr/bin/evil',
|
||||||
|
home: '/home/attacker',
|
||||||
|
cwd: '/tmp/attacker',
|
||||||
|
nativeSessionPath: '/var/native/attacker.jsonl',
|
||||||
|
} as unknown as Parameters<HarnessService['createSession']>[1];
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, hostile);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
||||||
|
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'ghost-harness',
|
||||||
|
providerId: 'p',
|
||||||
|
modelId: 'm',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('adapter_unavailable');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
// No substitution: the DTO tuple is exactly what was requested.
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const created = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
expect(created.state).toBe('idle');
|
||||||
|
expect(created.processId).toBeTruthy();
|
||||||
|
expect(created.attachedClientIds).toEqual([]);
|
||||||
|
|
||||||
|
const resumed = await service.resumeSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-resume',
|
||||||
|
nativeSessionId: 'native-preexisting-123',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
// Resume binds the supplied native session; create mints a fresh one.
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
||||||
|
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
await service.attach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
||||||
|
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
||||||
|
|
||||||
|
const afterDetach = await service.detach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
// Detach removes the browser attachment only; the process stays alive.
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).toBe('idle');
|
||||||
|
expect(afterDetach.processId).toBeTruthy();
|
||||||
|
|
||||||
|
const afterEvict = await service.evict(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'idle_timeout',
|
||||||
|
});
|
||||||
|
// Evict stops the process but retains the resumable native session.
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
const afterEnd = await service.end(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'session_ended',
|
||||||
|
});
|
||||||
|
// End destructively terminates the native session.
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails typed when an unsupported capability is exercised', async () => {
|
||||||
|
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
||||||
|
(capability) => capability !== 'extensionUi',
|
||||||
|
);
|
||||||
|
const { service, adapter } = setup(withoutExtensionUi);
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.respondInteraction(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted, server-derived authority. In production this is produced by the
|
||||||
|
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||||
|
*/
|
||||||
|
export interface TrustedGatewayScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||||
|
const FORBIDDEN_REQUEST_FIELDS = [
|
||||||
|
'actorId',
|
||||||
|
'tenantId',
|
||||||
|
'correlationId',
|
||||||
|
'seatId',
|
||||||
|
'seat',
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||||
|
export class HarnessScopeViolationError extends Error {
|
||||||
|
constructor(readonly field: string) {
|
||||||
|
super(`External request supplied server-authority field "${field}".`);
|
||||||
|
this.name = 'HarnessScopeViolationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvictSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespondInteractionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly response: HarnessInteractionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSession {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly handle: HarnessSessionHandle;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||||
|
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||||
|
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||||
|
* lifecycle operations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessService {
|
||||||
|
private readonly sessions = new Map<string, ActiveSession>();
|
||||||
|
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: CreateHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.create({ context, conversationId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: ResumeHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, nativeSessionId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: AttachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.attach({ clientId: request.clientId });
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: DetachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.detach(request.clientId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async evict(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EvictSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.evictProcess(request.reason);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EndSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.endSession(request.reason);
|
||||||
|
const snapshot = await handle.snapshot();
|
||||||
|
this.sessions.delete(request.conversationId);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: RespondInteractionRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.respondInteraction(request.response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
const handle = this.requireHandle(scope, conversationId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||||
|
try {
|
||||||
|
return this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
catalogPromise: Promise<HarnessCatalog>,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const catalog = await catalogPromise;
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||||
|
const active = this.sessions.get(conversationId);
|
||||||
|
if (!active) {
|
||||||
|
throw operationError(
|
||||||
|
'session_not_found',
|
||||||
|
`No active harness session for conversation "${conversationId}".`,
|
||||||
|
{ harnessId: '', providerId: '', modelId: '' },
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return active.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||||
|
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.seatId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject any request object that carries a server-authority field. */
|
||||||
|
function assertTrustedRequest(request: object): void {
|
||||||
|
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||||
|
throw new HarnessScopeViolationError(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the typed operation error so callers importing from the service
|
||||||
|
// have the discriminated failure type without reaching into the registry.
|
||||||
|
export { HarnessOperationError } from './harness.registry.js';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||||
|
*
|
||||||
|
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||||
|
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||||
|
*/
|
||||||
|
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||||
|
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||||
|
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||||
|
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'actor-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||||
|
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
describe('FakeHarnessAdapter no-substitution', () => {
|
||||||
|
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const available = catalog.models.find(
|
||||||
|
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||||
|
);
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
const selected: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection: selected,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(bogus);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
// The DTO echoes the exact requested tuple, unchanged.
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
// No substitution to the first catalog row.
|
||||||
|
expect(dto.selection).not.toEqual({
|
||||||
|
harnessId: firstRow.harnessId,
|
||||||
|
providerId: firstRow.providerId,
|
||||||
|
modelId: firstRow.modelId,
|
||||||
|
});
|
||||||
|
// The active selection is untouched by the rejected request.
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
|
||||||
|
const startingSelection: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-2',
|
||||||
|
selection: startingSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(requested);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
expect(dto.selection).toEqual(requested);
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import type {
|
||||||
|
AttachClient,
|
||||||
|
CreateHarnessSession,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessEvent,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessPrompt,
|
||||||
|
HarnessPromptReceipt,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
HarnessSessionState,
|
||||||
|
ResumeHarnessSession,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { operationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
export interface FakeHarnessAdapterOptions {
|
||||||
|
readonly id: string;
|
||||||
|
readonly capabilities?: readonly HarnessCapability[];
|
||||||
|
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAKE_PROVIDER = 'fake-openai';
|
||||||
|
|
||||||
|
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-mini',
|
||||||
|
displayName: 'Fake Mini',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-pro',
|
||||||
|
displayName: 'Fake Pro',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text', 'image'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-legacy',
|
||||||
|
displayName: 'Fake Legacy',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'unavailable',
|
||||||
|
availability: 'unavailable',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||||
|
return (
|
||||||
|
entry.harnessId === selection.harnessId &&
|
||||||
|
entry.providerId === selection.providerId &&
|
||||||
|
entry.modelId === selection.modelId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory harness session handle used by the fake adapter and by the shared
|
||||||
|
* conformance suite. It enforces the two invariants the real adapters must also
|
||||||
|
* honor: model selection is validated against the catalog and is NEVER
|
||||||
|
* substituted, and unsupported capabilities fail with a typed error.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||||
|
private state: HarnessSessionState = 'idle';
|
||||||
|
private processId: string | undefined;
|
||||||
|
private readonly attachedClientIds = new Set<string>();
|
||||||
|
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly conversationId: string,
|
||||||
|
private readonly nativeSessionId: string,
|
||||||
|
private readonly seatId: string,
|
||||||
|
private selection: HarnessSelection,
|
||||||
|
private readonly correlationId: string,
|
||||||
|
private readonly capabilities: readonly HarnessCapability[],
|
||||||
|
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||||
|
) {
|
||||||
|
this.processId = `process-${nativeSessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
nativeSessionId: this.nativeSessionId,
|
||||||
|
processId: this.processId,
|
||||||
|
seatId: this.seatId,
|
||||||
|
selection: this.selection,
|
||||||
|
state: this.state,
|
||||||
|
attachedClientIds: [...this.attachedClientIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(input: AttachClient): Promise<void> {
|
||||||
|
this.attachedClientIds.add(input.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(clientId: string): Promise<void> {
|
||||||
|
// Removes the browser attachment only; the process and native session persist.
|
||||||
|
this.attachedClientIds.delete(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
turnId: input.turnId,
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
state: 'accepted',
|
||||||
|
selection: this.selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||||
|
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||||
|
if (!entry) {
|
||||||
|
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.selection = selection;
|
||||||
|
return this.selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(_turnId: string): Promise<void> {
|
||||||
|
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||||
|
if (!this.capabilities.includes('extensionUi')) {
|
||||||
|
throw operationError(
|
||||||
|
'interaction_unsupported',
|
||||||
|
'This harness does not support interactive responses.',
|
||||||
|
this.selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events(listener: (event: HarnessEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Stop the process but keep the resumable native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'evicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Destructively end the native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'ended';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||||
|
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||||
|
* two paths are observably distinct.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||||
|
readonly id: string;
|
||||||
|
private readonly capabilities: readonly HarnessCapability[];
|
||||||
|
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||||
|
private createdCount = 0;
|
||||||
|
|
||||||
|
constructor(options: FakeHarnessAdapterOptions) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||||
|
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
displayName: `Fake harness (${this.id})`,
|
||||||
|
capabilities: this.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||||
|
return {
|
||||||
|
harnessId: this.id,
|
||||||
|
version: '1.0.0',
|
||||||
|
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||||
|
models: this.catalogEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
this.createdCount += 1;
|
||||||
|
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
input.nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'contract-actor',
|
||||||
|
tenantId: 'contract-tenant',
|
||||||
|
seatId: 'contract-seat',
|
||||||
|
correlationId: 'contract-correlation',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
||||||
|
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('contract fixture requires at least one catalog model');
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
return undefined;
|
||||||
|
} catch (caught) {
|
||||||
|
return caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
||||||
|
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
||||||
|
* the native Pi adapter so both share one behavioral contract.
|
||||||
|
*/
|
||||||
|
export function runHarnessAdapterContract(
|
||||||
|
label: string,
|
||||||
|
createAdapter: () => HarnessAdapter,
|
||||||
|
): void {
|
||||||
|
describe(`harness adapter contract: ${label}`, () => {
|
||||||
|
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
||||||
|
).snapshot();
|
||||||
|
const resumed = await (
|
||||||
|
await adapter.resume({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-resume',
|
||||||
|
nativeSessionId: 'native-supplied-1',
|
||||||
|
selection,
|
||||||
|
})
|
||||||
|
).snapshot();
|
||||||
|
|
||||||
|
expect(created.nativeSessionId).toBeTruthy();
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
||||||
|
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
||||||
|
expect(created.seatId).toBe(CONTEXT.seatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-lifecycle',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.attach({ clientId: 'browser-1' });
|
||||||
|
await handle.detach('browser-1');
|
||||||
|
const afterDetach = await handle.snapshot();
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).not.toBe('evicted');
|
||||||
|
expect(afterDetach.state).not.toBe('ended');
|
||||||
|
|
||||||
|
await handle.evictProcess('idle_timeout');
|
||||||
|
const afterEvict = await handle.snapshot();
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
// The native session survives eviction (resumable); the process does not.
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
|
||||||
|
await handle.endSession('session_ended');
|
||||||
|
const afterEnd = await handle.snapshot();
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
// End is not an alias of evict.
|
||||||
|
expect(afterEnd.state).not.toBe(afterEvict.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never substitutes the first catalog row for an unknown selection', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('contract fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const start = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-nosub',
|
||||||
|
selection: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: adapter.id,
|
||||||
|
providerId: 'contract-ghost-provider',
|
||||||
|
modelId: 'contract-ghost-model',
|
||||||
|
};
|
||||||
|
const error = await captureError(() => handle.setModel(bogus));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const descriptor = await adapter.describe(CONTEXT);
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-interaction',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
requestId: 'interaction-1',
|
||||||
|
type: 'confirm',
|
||||||
|
accepted: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (descriptor.capabilities.includes('extensionUi')) {
|
||||||
|
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const error = await captureError(() => handle.respondInteraction(response));
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||||
|
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapPreflightResult {
|
||||||
|
capturedConfig: MosaicConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBootstrapPreflight(
|
||||||
|
anchoredConfigContents: string,
|
||||||
|
ambientConfigContents: string,
|
||||||
|
): Promise<BootstrapPreflightResult> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let capturedConfig: MosaicConfig | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||||
|
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||||
|
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
const exitMock = vi.fn<typeof process.exit>();
|
||||||
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
vi.doMock('./tracing.js', () => ({}));
|
||||||
|
|
||||||
|
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||||
|
vi.doMock('@mosaicstack/storage', async () => {
|
||||||
|
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||||
|
capturedConfig = config;
|
||||||
|
throw preflightSentinel;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('./main.js');
|
||||||
|
await vi.waitFor((): void => {
|
||||||
|
expect(exitSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { capturedConfig };
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
exitSpy?.mockRestore();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
vi.doUnmock('@mosaicstack/storage');
|
||||||
|
vi.doUnmock('./tracing.js');
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('main bootstrap preflight config anchoring', (): void => {
|
||||||
|
it(
|
||||||
|
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const anchoredConfig = JSON.stringify({
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
const ambientConfig = JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||||
|
|
||||||
|
expect(capturedConfig?.tier).toBe('local');
|
||||||
|
expect(capturedConfig?.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { config } from 'dotenv';
|
import './env.js';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode).
|
|
||||||
// Loaded first so monorepo .env can override for local dev.
|
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
|
||||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig();
|
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|||||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
const executor = new CommandExecutorService(
|
const executor = new CommandExecutorService(
|
||||||
registry as never,
|
registry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
@@ -152,7 +158,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
reloadService,
|
reloadService,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
null,
|
mockMcpClient as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"build:vite": "vite build",
|
"build:vite": "vite build",
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3101",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"start": "next start"
|
"start": "next start -p 3101"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
|
|||||||
@@ -1,3 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
// The exact harness/provider/model tuple and its closed enum companions are the
|
||||||
|
// shared domain types — re-exported here so web consumers (and the runtime
|
||||||
|
// guards) import one shape, never a divergent local redefinition.
|
||||||
|
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
|
||||||
|
|
||||||
|
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
|
||||||
|
* harness id is kept distinct from any provider id — they are never merged. */
|
||||||
|
export interface HarnessSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One selectable model in a harness catalog. Extends the `{harnessId,
|
||||||
|
* providerId, modelId}` tuple with the display/availability metadata the UI
|
||||||
|
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
|
||||||
|
* because it arrives from untrusted JSON and is only ever displayed. */
|
||||||
|
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||||
|
displayName: string;
|
||||||
|
reasoningCapability: boolean;
|
||||||
|
inputTypes: string[];
|
||||||
|
authState: HarnessAuthState;
|
||||||
|
availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
|
||||||
|
export interface HarnessCatalog {
|
||||||
|
harnessId: string;
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
models: HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Conversation returned by the gateway API. */
|
/** Conversation returned by the gateway API. */
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+19
-3
@@ -5,6 +5,14 @@ import { RegisterPage } from '@/spa/pages/register';
|
|||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||||
|
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||||
|
import { ProjectsPage } from '@/spa/pages/projects';
|
||||||
|
import {
|
||||||
|
ProjectDetailRouteErrorBoundary,
|
||||||
|
ProjectsRouteErrorBoundary,
|
||||||
|
TasksRouteErrorBoundary,
|
||||||
|
} from '@/spa/pages/resource-route-error-boundaries';
|
||||||
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
@@ -37,9 +45,17 @@ export const routes: RouteObject[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
{
|
||||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
path: '/projects',
|
||||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
element: <ProjectsPage />,
|
||||||
|
errorElement: <ProjectsRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:id',
|
||||||
|
element: <ProjectDetailPage />,
|
||||||
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
} from './chat-api';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(): ReturnType<typeof vi.fn> {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every URL the client actually requested, across all calls. */
|
||||||
|
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => String(call[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat-api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const harnesses = await fetchHarnesses();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
||||||
|
expect(harnesses).toEqual([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'abc123',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('pi');
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw new Error('expected ok catalog');
|
||||||
|
expect(result.catalog.harnessId).toBe('pi');
|
||||||
|
expect(result.catalog.models).toHaveLength(1);
|
||||||
|
expect(result.catalog.models[0]).toMatchObject({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
availability: 'available',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'adapter_unavailable',
|
||||||
|
message: 'raw gateway detail that must not leak verbatim',
|
||||||
|
harnessId: 'attacker-echo',
|
||||||
|
extra: { hostile: 'blob' },
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('ghost');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected unavailable result');
|
||||||
|
expect(result.code).toBe('catalog_unavailable');
|
||||||
|
// harnessId comes from the request, never the (untrusted) response body.
|
||||||
|
expect(result.harnessId).toBe('ghost');
|
||||||
|
expect(typeof result.message).toBe('string');
|
||||||
|
// The raw response body is never rendered/returned verbatim.
|
||||||
|
expect(JSON.stringify(result)).not.toContain('hostile');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await persistSelection({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
const init = call?.[1] as RequestInit;
|
||||||
|
expect(String(init.method).toUpperCase()).toBe('PUT');
|
||||||
|
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'raw detail that must not leak',
|
||||||
|
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected failed persist');
|
||||||
|
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
||||||
|
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
||||||
|
expect(result.requested).toEqual(requested);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('raw detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(json([]));
|
||||||
|
await fetchHarnesses();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
||||||
|
);
|
||||||
|
await fetchCatalog('pi');
|
||||||
|
fetchMock.mockResolvedValue(json({ selection: null }));
|
||||||
|
await fetchPersistedSelection();
|
||||||
|
|
||||||
|
for (const url of requestedUrls(fetchMock)) {
|
||||||
|
expect(url).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
||||||
|
* UI depends on. Every response body is untrusted and is normalized through the
|
||||||
|
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
||||||
|
* are mapped to typed, body-free error results so a raw gateway body is never
|
||||||
|
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
||||||
|
*
|
||||||
|
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
||||||
|
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
||||||
|
*/
|
||||||
|
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
||||||
|
* never a thrown raw body. */
|
||||||
|
export type CatalogResult =
|
||||||
|
| { ok: true; catalog: HarnessCatalog }
|
||||||
|
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
||||||
|
|
||||||
|
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
||||||
|
|
||||||
|
/** A persist either confirms the stored tuple or reports a typed domain failure
|
||||||
|
* that echoes back the exact tuple the caller requested. */
|
||||||
|
export type SelectionPersistResult =
|
||||||
|
| { ok: true; selection: HarnessSelection }
|
||||||
|
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
||||||
|
|
||||||
|
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
||||||
|
* never surfaced. */
|
||||||
|
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
||||||
|
|
||||||
|
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
||||||
|
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
||||||
|
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
||||||
|
* read from the body. */
|
||||||
|
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
|
return response.json().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
||||||
|
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||||
|
const code = (body as { code: unknown }).code;
|
||||||
|
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
||||||
|
}
|
||||||
|
// Default to the more conservative "invalid" classification for anything
|
||||||
|
// unrecognized rather than guessing "model_unavailable".
|
||||||
|
return 'selection_invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
||||||
|
* normalizes to an empty list (the UI then has no harness to select). */
|
||||||
|
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
||||||
|
const response = await fetch('/api/harnesses', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return asHarnessSummaries(await readJson(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
||||||
|
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
||||||
|
* fallback catalog or a rendered raw body. */
|
||||||
|
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
||||||
|
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'catalog_unavailable',
|
||||||
|
// Scoped to the requested harness id, never the untrusted body's echo.
|
||||||
|
harnessId,
|
||||||
|
message: CATALOG_UNAVAILABLE_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
||||||
|
* unset or malformed. */
|
||||||
|
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const body = await readJson(response);
|
||||||
|
if (typeof body !== 'object' || body === null) return null;
|
||||||
|
return asHarnessSelection((body as { selection?: unknown }).selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
||||||
|
* On success returns the confirmed selection; on a typed domain failure (422)
|
||||||
|
* or validation error, returns a typed result carrying the EXACT requested
|
||||||
|
* tuple — never the body's echo — and never the raw body text. */
|
||||||
|
export async function persistSelection(
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<SelectionPersistResult> {
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
modelId: selection.modelId,
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requested),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await readJson(response);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: safeSelectionCode(body),
|
||||||
|
// Fixed copy only — the untrusted body's message is never surfaced.
|
||||||
|
message: SELECTION_REJECTED_MESSAGE,
|
||||||
|
requested,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = await readJson(response);
|
||||||
|
const confirmed =
|
||||||
|
typeof body === 'object' && body !== null
|
||||||
|
? asHarnessSelection((body as { selection?: unknown }).selection)
|
||||||
|
: null;
|
||||||
|
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
||||||
|
// the server accepted the tuple, so the requested tuple is the source of truth.
|
||||||
|
return { ok: true, selection: confirmed ?? requested };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
@@ -9,6 +10,23 @@ interface ComposerProps {
|
|||||||
* pre-ack window where a second send could otherwise slip through. */
|
* pre-ack window where a second send could otherwise slip through. */
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
hasConversation: boolean;
|
hasConversation: boolean;
|
||||||
|
/** Structured harness/provider/model selection state. The composer never
|
||||||
|
* accepts free-text provider/model — every sendable tuple is a validated,
|
||||||
|
* persisted catalog entry, and the send projection is derived from it. */
|
||||||
|
harness: HarnessSelectionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct provider ids present in the current catalog, in first-seen
|
||||||
|
* order — the provider select is catalog-derived, never a hardcoded list. */
|
||||||
|
function providerOptions(harness: HarnessSelectionValue): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const model of harness.catalog?.models ?? []) {
|
||||||
|
if (seen.has(model.providerId)) continue;
|
||||||
|
seen.add(model.providerId);
|
||||||
|
out.push(model.providerId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
@@ -17,21 +35,19 @@ export function Composer({
|
|||||||
streaming,
|
streaming,
|
||||||
sending,
|
sending,
|
||||||
hasConversation,
|
hasConversation,
|
||||||
|
harness,
|
||||||
}: ComposerProps): ReactElement {
|
}: ComposerProps): ReactElement {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [provider, setProvider] = useState('');
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const busy = streaming || sending;
|
const busy = streaming || sending;
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||||
|
// selection can never emit, so provider/model never travel as free text.
|
||||||
|
if (!harness.canSend) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
onSend({
|
onSend({ content: trimmed, ...harness.projection });
|
||||||
content: trimmed,
|
|
||||||
provider: provider.trim() || undefined,
|
|
||||||
modelId: modelId.trim() || undefined,
|
|
||||||
});
|
|
||||||
setContent('');
|
setContent('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +58,19 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scope the model options to the intentionally selected provider. With no
|
||||||
|
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
||||||
|
// offers only the placeholder — never a cross-provider row.
|
||||||
|
const models = (harness.catalog?.models ?? []).filter(
|
||||||
|
(model) => model.providerId === harness.providerId,
|
||||||
|
);
|
||||||
|
// A collision-safe composite option identity covering the full provider+model
|
||||||
|
// tuple. The controlled select mirrors the same identity so the exact catalog
|
||||||
|
// row highlights (a bare modelId would collide across providers).
|
||||||
|
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
||||||
|
`${model.providerId}:${model.modelId}`;
|
||||||
|
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -51,21 +80,68 @@ export function Composer({
|
|||||||
className="flex flex-col gap-2 border-t p-4"
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<input
|
<select
|
||||||
|
aria-label="Harness"
|
||||||
|
value={harness.harnessId}
|
||||||
|
onChange={(event) => harness.selectHarness(event.target.value)}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Select a harness…</option>
|
||||||
|
{harness.harnesses.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
aria-label="Provider"
|
aria-label="Provider"
|
||||||
value={provider}
|
value={harness.providerId}
|
||||||
onChange={(event) => setProvider(event.target.value)}
|
onChange={(event) => harness.selectProvider(event.target.value)}
|
||||||
placeholder="Provider (optional)"
|
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
className="rounded border px-2 py-1 text-xs"
|
||||||
/>
|
>
|
||||||
<input
|
<option value="">Select a provider…</option>
|
||||||
|
{providerOptions(harness).map((providerId) => (
|
||||||
|
<option key={providerId} value={providerId}>
|
||||||
|
{providerId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
aria-label="Model"
|
aria-label="Model"
|
||||||
value={modelId}
|
value={selectedModelValue}
|
||||||
onChange={(event) => setModelId(event.target.value)}
|
onChange={(event) => {
|
||||||
placeholder="Model (optional)"
|
// Resolve the composite option identity back to the exact catalog
|
||||||
|
// row and persist that row's own provider+model — never a bare id.
|
||||||
|
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
||||||
|
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
||||||
|
}}
|
||||||
|
disabled={harness.catalogUnavailable || models.length === 0}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
className="rounded border px-2 py-1 text-xs"
|
||||||
/>
|
>
|
||||||
|
<option value="">Select a model…</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
||||||
|
{model.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{harness.catalogUnavailable ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
This harness catalog is currently unavailable.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.isStale ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
The saved model is no longer available — pick another to continue.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.persistError ? (
|
||||||
|
<p role="alert" className="text-xs">
|
||||||
|
{harness.persistError.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
@@ -78,7 +154,7 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!content.trim() || busy}
|
disabled={!content.trim() || busy || !harness.canSend}
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||||
* child.
|
* child.
|
||||||
*/
|
*/
|
||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSummary,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
export function asString(value: unknown, fallback = ''): string {
|
export function asString(value: unknown, fallback = ''): string {
|
||||||
return typeof value === 'string' ? value : fallback;
|
return typeof value === 'string' ? value : fallback;
|
||||||
@@ -38,6 +46,99 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
||||||
|
* payloads above — a misbehaving or compromised gateway can send anything. The
|
||||||
|
* guards below normalize those bodies into the typed client shapes without ever
|
||||||
|
* rendering a raw body, so a 404/422/malformed response can never inject an
|
||||||
|
* object into React or a non-tuple into the selection state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
||||||
|
* safest value (`unavailable`) for anything unrecognized. */
|
||||||
|
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
||||||
|
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
||||||
|
? value
|
||||||
|
: 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
||||||
|
* `unavailable` so a malformed row can never present as sendable. */
|
||||||
|
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
||||||
|
return value === 'available' ? 'available' : 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
||||||
|
* or malformed selection is rejected (null) rather than half-adopted. */
|
||||||
|
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const harnessId = value.harnessId;
|
||||||
|
const providerId = value.providerId;
|
||||||
|
const modelId = value.modelId;
|
||||||
|
if (
|
||||||
|
typeof harnessId !== 'string' ||
|
||||||
|
typeof providerId !== 'string' ||
|
||||||
|
typeof modelId !== 'string' ||
|
||||||
|
harnessId.length === 0 ||
|
||||||
|
providerId.length === 0 ||
|
||||||
|
modelId.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { harnessId, providerId, modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
||||||
|
* without a usable id. */
|
||||||
|
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: HarnessSummary[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const id = asString(item.id);
|
||||||
|
if (id.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
displayName: asNonEmptyString(item.displayName, id),
|
||||||
|
capabilities: asStringArray(item.capabilities),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
||||||
|
const selection = asHarnessSelection(value);
|
||||||
|
if (selection === null || !isRecord(value)) return null;
|
||||||
|
return {
|
||||||
|
...selection,
|
||||||
|
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
||||||
|
reasoningCapability: value.reasoningCapability === true,
|
||||||
|
inputTypes: asStringArray(value.inputTypes),
|
||||||
|
authState: asHarnessAuthState(value.authState),
|
||||||
|
availability: asHarnessAvailability(value.availability),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
||||||
|
* caller supplies `harnessId` (from the request path) so the returned catalog
|
||||||
|
* is scoped to the harness that was actually requested, never a body-echoed id.
|
||||||
|
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
||||||
|
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
||||||
|
const record = isRecord(value) ? value : {};
|
||||||
|
const rawModels = Array.isArray(record.models) ? record.models : [];
|
||||||
|
const models: HarnessCatalogEntry[] = [];
|
||||||
|
for (const row of rawModels) {
|
||||||
|
const entry = asHarnessCatalogEntry(row);
|
||||||
|
if (entry !== null) models.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
harnessId,
|
||||||
|
version: asString(record.version),
|
||||||
|
fingerprint: asString(record.fingerprint),
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The single point of truth for what counts as a valid conversation ID
|
/** The single point of truth for what counts as a valid conversation ID
|
||||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||||
* string, nothing else. Every site that establishes or compares
|
* string, nothing else. Every site that establishes or compares
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { act, type ReactElement } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
harnesses?: unknown;
|
||||||
|
catalog?: { body: unknown; status?: number };
|
||||||
|
selection?: unknown;
|
||||||
|
/** When set, the PUT resolves only when this is called (for race tests). */
|
||||||
|
deferPut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defer<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
let putBodies: unknown[] = [];
|
||||||
|
let putDeferred: Deferred<Response> | null = null;
|
||||||
|
|
||||||
|
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
||||||
|
putBodies = [];
|
||||||
|
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
const spec = scenario.catalog ?? {
|
||||||
|
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
||||||
|
};
|
||||||
|
return json(spec.body, spec.status ?? 200);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return json({ selection: scenario.selection ?? null });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
putBodies.push(JSON.parse(String(init?.body)));
|
||||||
|
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
if (putDeferred) return putDeferred.promise;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest: HarnessSelectionValue | null = null;
|
||||||
|
|
||||||
|
function Probe(): ReactElement | null {
|
||||||
|
latest = useHarnessSelection();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
latest = null;
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe />);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(): HarnessSelectionValue {
|
||||||
|
if (!latest) throw new Error('hook value not captured');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PI_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
displayName: 'Claude',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useHarnessSelection', () => {
|
||||||
|
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
expect(value().catalog).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.harnessId).toBe('pi');
|
||||||
|
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
// Nothing was persisted — no PUT fired for an unset selection.
|
||||||
|
const putCalls = fetchMock.mock.calls.filter(
|
||||||
|
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
||||||
|
);
|
||||||
|
expect(putCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
// The persisted tuple is displayed even though its model is gone from the catalog.
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'retired-model',
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('retired-model');
|
||||||
|
expect(value().isStale).toBe(true);
|
||||||
|
// A stale model is not a valid catalog option, so send stays disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.models ?? []).toHaveLength(0);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalogUnavailable).toBe(true);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: {
|
||||||
|
...PI_CATALOG,
|
||||||
|
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
// Override PUT to 422.
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses')
|
||||||
|
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
||||||
|
return json({ selection: null });
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'nope',
|
||||||
|
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response('nf', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('gpt-5');
|
||||||
|
expect(value().persistError?.code).toBe('model_unavailable');
|
||||||
|
expect(value().persistError?.requested).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// A valid provider-A tuple has persisted.
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching provider clears the model that no longer belongs to it.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
// Send stays disabled until the new exact provider-B tuple persists.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
});
|
||||||
|
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
for (const call of fetchMock.mock.calls) {
|
||||||
|
expect(String(call[0])).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
type SelectionErrorCode,
|
||||||
|
} from './chat-api';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface HarnessPersistError {
|
||||||
|
code: SelectionErrorCode;
|
||||||
|
message: string;
|
||||||
|
/** The exact tuple the user requested — preserved so the failed selection
|
||||||
|
* stays visible rather than being silently dropped. */
|
||||||
|
requested: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSelectionValue {
|
||||||
|
harnesses: HarnessSummary[];
|
||||||
|
catalog: HarnessCatalog | null;
|
||||||
|
/** True when the selected harness has no usable catalog (404/error). */
|
||||||
|
catalogUnavailable: boolean;
|
||||||
|
/** The working (displayed) selection, kept as three distinct ids. Empty
|
||||||
|
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
||||||
|
harnessId: string;
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
/** The last tuple confirmed persisted by the server, or null. */
|
||||||
|
persistedSelection: HarnessSelection | null;
|
||||||
|
/** True when a persisted selection references a model no longer present as an
|
||||||
|
* available catalog entry — it stays visibly displayed rather than dropped. */
|
||||||
|
isStale: boolean;
|
||||||
|
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
||||||
|
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
||||||
|
* can never race ahead of successful persistence. */
|
||||||
|
canSend: boolean;
|
||||||
|
persistError: HarnessPersistError | null;
|
||||||
|
selectHarness: (harnessId: string) => void;
|
||||||
|
selectProvider: (providerId: string) => void;
|
||||||
|
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
||||||
|
* resolves the composite option identity to the real entry and passes both
|
||||||
|
* ids, so a bare model id is never combined with ambient provider state. */
|
||||||
|
selectModel: (providerId: string, modelId: string) => void;
|
||||||
|
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
||||||
|
* send path — derived ONLY from the validated persisted tuple, never from any
|
||||||
|
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
||||||
|
projection: { provider?: string; modelId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||||
|
* matching, available entry — the single gate that keeps a stale/unavailable
|
||||||
|
* model from ever counting as sendable. */
|
||||||
|
function isAvailableInCatalog(
|
||||||
|
selection: HarnessSelection | null,
|
||||||
|
catalog: HarnessCatalog | null,
|
||||||
|
): boolean {
|
||||||
|
if (selection === null || catalog === null) return false;
|
||||||
|
return catalog.models.some(
|
||||||
|
(model) =>
|
||||||
|
model.providerId === selection.providerId &&
|
||||||
|
model.modelId === selection.modelId &&
|
||||||
|
model.availability === 'available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the harness/catalog/selection state for the chat composer: loads the
|
||||||
|
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
||||||
|
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
||||||
|
* when a model is picked. It never auto-selects a catalog row, keeps a
|
||||||
|
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
||||||
|
* once a full tuple has actually persisted as an available catalog entry.
|
||||||
|
*/
|
||||||
|
export function useHarnessSelection(): HarnessSelectionValue {
|
||||||
|
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
||||||
|
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
||||||
|
const [harnessId, setHarnessId] = useState('');
|
||||||
|
const [providerId, setProviderId] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
||||||
|
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
||||||
|
|
||||||
|
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
||||||
|
// overwrite the result of a newer request the user has since triggered.
|
||||||
|
const catalogRequestRef = useRef(0);
|
||||||
|
const persistRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
||||||
|
const requestId = catalogRequestRef.current + 1;
|
||||||
|
catalogRequestRef.current = requestId;
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
const result = await fetchCatalog(id);
|
||||||
|
if (catalogRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setCatalog(result.catalog);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
} else {
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
||||||
|
if (!active) return;
|
||||||
|
setHarnesses(list);
|
||||||
|
if (persisted !== null) {
|
||||||
|
// Adopt the persisted tuple as the displayed selection and load its
|
||||||
|
// catalog. If the model has since been retired, it still shows (stale).
|
||||||
|
setHarnessId(persisted.harnessId);
|
||||||
|
setProviderId(persisted.providerId);
|
||||||
|
setModelId(persisted.modelId);
|
||||||
|
setPersistedSelection(persisted);
|
||||||
|
await loadCatalog(persisted.harnessId);
|
||||||
|
}
|
||||||
|
// No persisted selection → nothing is auto-selected; the user must choose.
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const selectHarness = useCallback(
|
||||||
|
(id: string): void => {
|
||||||
|
setHarnessId(id);
|
||||||
|
// Changing harness invalidates the provider/model draft — never carry a
|
||||||
|
// model across harnesses.
|
||||||
|
setProviderId('');
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
void loadCatalog(id);
|
||||||
|
},
|
||||||
|
[loadCatalog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProvider = useCallback((id: string): void => {
|
||||||
|
setProviderId(id);
|
||||||
|
// A new provider invalidates the chosen model — no cross-provider carryover.
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectModel = useCallback(
|
||||||
|
(selectedProviderId: string, selectedModelId: string): void => {
|
||||||
|
// Bind the model to the EXACT catalog row's provider — never to ambient
|
||||||
|
// provider state — so two providers exposing the same modelId can never
|
||||||
|
// collide or mis-resolve. Keep the displayed provider consistent with the
|
||||||
|
// resolved row.
|
||||||
|
setProviderId(selectedProviderId);
|
||||||
|
setModelId(selectedModelId);
|
||||||
|
setPersistError(null);
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId,
|
||||||
|
providerId: selectedProviderId,
|
||||||
|
modelId: selectedModelId,
|
||||||
|
};
|
||||||
|
const requestId = persistRequestRef.current + 1;
|
||||||
|
persistRequestRef.current = requestId;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
if (persistRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setPersistedSelection(result.selection);
|
||||||
|
setPersistError(null);
|
||||||
|
} else {
|
||||||
|
// Leave persistedSelection unchanged (send stays disabled) and surface
|
||||||
|
// the typed error carrying the exact requested tuple.
|
||||||
|
setPersistError({
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
requested: result.requested,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[harnessId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
||||||
|
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
const canSend =
|
||||||
|
persistedSelection !== null &&
|
||||||
|
!catalogUnavailable &&
|
||||||
|
tuplesEqual(draft, persistedSelection) &&
|
||||||
|
isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
const projection: { provider?: string; modelId?: string } = canSend
|
||||||
|
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
harnesses,
|
||||||
|
catalog,
|
||||||
|
catalogUnavailable,
|
||||||
|
harnessId,
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
persistedSelection,
|
||||||
|
isStale,
|
||||||
|
canSend,
|
||||||
|
persistError,
|
||||||
|
selectHarness,
|
||||||
|
selectProvider,
|
||||||
|
selectModel,
|
||||||
|
projection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,76 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
displayName: 'Claude',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A harness/catalog/selection HTTP stub for the chat-api the selection hook
|
||||||
|
* drives. `selection` seeds the persisted tuple returned by the GET (a valid
|
||||||
|
* in-catalog tuple by default, so `canSend` settles true after mount). */
|
||||||
|
function harnessFetch(
|
||||||
|
selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
): typeof fetch {
|
||||||
|
return vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') {
|
||||||
|
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
return jsonResponse(DEFAULT_CATALOG);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return jsonResponse({ selection });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains the selection hook's chained mount fetches (harnesses → selection →
|
||||||
|
* catalog) and any pending PUT so derived `canSend` settles before assertions. */
|
||||||
|
async function flushAsync(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
let root: Root | null;
|
let root: Root | null;
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
@@ -57,12 +127,16 @@ beforeEach(async () => {
|
|||||||
fake = createFakeChatSocket();
|
fake = createFakeChatSocket();
|
||||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
destroySocketMock.mockReset();
|
destroySocketMock.mockReset();
|
||||||
|
vi.stubGlobal('fetch', harnessFetch());
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<ChatPage />);
|
root?.render(<ChatPage />);
|
||||||
});
|
});
|
||||||
|
// Settle the selection hook's mount fetches so the default in-catalog tuple
|
||||||
|
// persists and `canSend` is true for the existing send-path tests.
|
||||||
|
await flushAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -70,8 +144,23 @@ afterEach(async () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
});
|
});
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for
|
||||||
|
* tests that need a non-default selection scenario. */
|
||||||
|
async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchImpl);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<ChatPage />);
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
describe('ChatPage', () => {
|
||||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -390,24 +479,62 @@ describe('ChatPage', () => {
|
|||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => {
|
||||||
|
// The old free-text inputs are gone.
|
||||||
|
expect(container.querySelector('input[aria-label="Provider"]')).toBeNull();
|
||||||
|
expect(container.querySelector('input[aria-label="Model"]')).toBeNull();
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
expect(harnessSelect).toBeTruthy();
|
||||||
|
expect(providerSelect).toBeTruthy();
|
||||||
|
expect(modelSelect).toBeTruthy();
|
||||||
|
// Harness and provider are distinct controls carrying distinct identifiers.
|
||||||
|
expect(harnessSelect).not.toBe(providerSelect);
|
||||||
|
expect([...harnessSelect.options].map((o) => o.value)).toContain('pi');
|
||||||
|
expect([...providerSelect.options].map((o) => o.value)).toContain('openai');
|
||||||
|
expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic');
|
||||||
|
// The model options are catalog-derived (not hardcoded) and scoped to the
|
||||||
|
// selected provider (openai, from the persisted tuple) using a collision-safe
|
||||||
|
// composite identity — the anthropic row is absent, not a bare 'claude'.
|
||||||
|
const modelValues = [...modelSelect.options].map((o) => o.value);
|
||||||
|
expect(modelValues).toContain('openai:gpt-5');
|
||||||
|
expect(modelValues).not.toContain('anthropic:claude');
|
||||||
|
expect(modelValues).not.toContain('claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => {
|
||||||
const textarea = container.querySelector(
|
const textarea = container.querySelector(
|
||||||
'textarea[aria-label="Message"]',
|
'textarea[aria-label="Message"]',
|
||||||
) as HTMLTextAreaElement;
|
) as HTMLTextAreaElement;
|
||||||
const providerInput = container.querySelector(
|
|
||||||
'input[aria-label="Provider"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
|
||||||
|
|
||||||
const stopButtonBefore = container.querySelector(
|
const stopButtonBefore = container.querySelector(
|
||||||
'button[aria-label="Stop"]',
|
'button[aria-label="Stop"]',
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
expect(stopButtonBefore.disabled).toBe(true);
|
expect(stopButtonBefore.disabled).toBe(true);
|
||||||
|
|
||||||
|
// Choose a fresh tuple from the catalog and let it persist.
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'anthropic');
|
||||||
|
});
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
// Composite provider+model option identity (provider was switched to
|
||||||
|
// anthropic above); the bare 'claude' no longer identifies an option.
|
||||||
|
selectValue(modelSelect, 'anthropic:claude');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
setValue(textarea, 'hello there');
|
setValue(textarea, 'hello there');
|
||||||
setValue(providerInput, 'anthropic');
|
|
||||||
setValue(modelInput, 'claude');
|
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
textarea.dispatchEvent(
|
textarea.dispatchEvent(
|
||||||
@@ -415,6 +542,7 @@ describe('ChatPage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The projected provider/model come from the validated persisted tuple.
|
||||||
expect(fake.emitted).toContainEqual({
|
expect(fake.emitted).toContainEqual({
|
||||||
event: 'message',
|
event: 'message',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -443,6 +571,28 @@ describe('ChatPage', () => {
|
|||||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables send until a selection has persisted — no send with an unset selection', async () => {
|
||||||
|
await remountWithFetch(harnessFetch(null));
|
||||||
|
|
||||||
|
const sendButton = findButton(container, 'Send');
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'should not send');
|
||||||
|
});
|
||||||
|
// Content present, but no selection persisted → Send stays disabled.
|
||||||
|
expect(sendButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||||
const textarea = container.querySelector(
|
const textarea = container.querySelector(
|
||||||
'textarea[aria-label="Message"]',
|
'textarea[aria-label="Message"]',
|
||||||
@@ -620,6 +770,134 @@ describe('ChatPage', () => {
|
|||||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => {
|
||||||
|
await remountWithFetch(harnessFetch(null));
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(harnessSelect, 'pi');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||||
|
// Only the selected provider's models are offered — provider B's model
|
||||||
|
// (anthropic:claude) is absent, so a user cannot pick across providers.
|
||||||
|
expect(optionValues).toEqual(['openai:gpt-5']);
|
||||||
|
expect(optionValues).not.toContain('anthropic:claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => {
|
||||||
|
const COLLIDING_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'alpha',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
displayName: 'Alpha GPT-X',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'beta',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
displayName: 'Beta GPT-X',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') {
|
||||||
|
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
return jsonResponse(COLLIDING_CATALOG);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return jsonResponse({ selection: null });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await remountWithFetch(collidingFetch);
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(harnessSelect, 'pi');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'alpha');
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
// The colliding modelId is provider-qualified in the option value, never a
|
||||||
|
// bare id, so the two providers' 'gpt-x' rows are uniquely identifiable.
|
||||||
|
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||||
|
expect(optionValues).toEqual(['alpha:gpt-x']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(modelSelect, 'alpha:gpt-x');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
// The controlled select highlights the alpha row via the composite identity.
|
||||||
|
expect(modelSelect.value).toBe('alpha:gpt-x');
|
||||||
|
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'ping');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The persisted/sent tuple resolves to provider alpha — NOT beta — even
|
||||||
|
// though the bare modelId 'gpt-x' exists under both providers.
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'ping',
|
||||||
|
provider: 'alpha',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
|||||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||||
|
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
|
||||||
|
|
||||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||||
@@ -23,6 +24,7 @@ function formatCost(value: unknown): string {
|
|||||||
|
|
||||||
export function ChatPage(): ReactElement {
|
export function ChatPage(): ReactElement {
|
||||||
const { state, actions } = useChatConnection();
|
const { state, actions } = useChatConnection();
|
||||||
|
const harness = useHarnessSelection();
|
||||||
const hasConversation = state.conversationId !== null;
|
const hasConversation = state.conversationId !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +88,7 @@ export function ChatPage(): ReactElement {
|
|||||||
streaming={state.streaming}
|
streaming={state.streaming}
|
||||||
sending={state.sending}
|
sending={state.sending}
|
||||||
hasConversation={hasConversation}
|
hasConversation={hasConversation}
|
||||||
|
harness={harness}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Mission, Project, Task } from '@/lib/types';
|
||||||
|
|
||||||
|
export const projectFixtures: Project[] = [
|
||||||
|
{
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Mosaic Stack',
|
||||||
|
description: 'Gateway and dashboard parity work',
|
||||||
|
status: 'active',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {
|
||||||
|
prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.',
|
||||||
|
},
|
||||||
|
createdAt: '2026-08-01T12:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-09T18:30:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-2',
|
||||||
|
name: 'Agent Runtime',
|
||||||
|
description: 'Pi SDK integration',
|
||||||
|
status: 'paused',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-02T08:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-05T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const missionFixtures: Mission[] = [
|
||||||
|
{
|
||||||
|
id: 'mission-1',
|
||||||
|
name: 'Ship web parity',
|
||||||
|
description: 'Port read-only routes into the SPA',
|
||||||
|
status: 'active',
|
||||||
|
projectId: 'project-1',
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-03T12:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-09T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mission-2',
|
||||||
|
name: 'Unrelated mission',
|
||||||
|
description: 'Must be filtered out of the project detail view',
|
||||||
|
status: 'planning',
|
||||||
|
projectId: 'project-2',
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-04T12:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-04T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const taskFixtures: Task[] = [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Route /projects',
|
||||||
|
description: 'Port the read-only projects listing into the SPA',
|
||||||
|
status: 'done',
|
||||||
|
priority: 'high',
|
||||||
|
projectId: 'project-1',
|
||||||
|
missionId: 'mission-1',
|
||||||
|
assignee: 'Jarvis',
|
||||||
|
tags: ['spa', 'projects'],
|
||||||
|
dueDate: '2026-08-12T00:00:00.000Z',
|
||||||
|
metadata: {
|
||||||
|
notes: 'Read-only modal content should remain intact.',
|
||||||
|
pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }],
|
||||||
|
},
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-09T15:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
title: 'Route /projects/:id',
|
||||||
|
description: 'Reuse overview, tasks, missions, and PRD tabs',
|
||||||
|
status: 'in-progress',
|
||||||
|
priority: 'critical',
|
||||||
|
projectId: 'project-1',
|
||||||
|
missionId: 'mission-1',
|
||||||
|
assignee: null,
|
||||||
|
tags: ['spa', 'detail'],
|
||||||
|
dueDate: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-05T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-3',
|
||||||
|
title: 'Route /tasks',
|
||||||
|
description: 'Wire list and kanban modal interactions',
|
||||||
|
status: 'blocked',
|
||||||
|
priority: 'medium',
|
||||||
|
projectId: 'project-1',
|
||||||
|
missionId: null,
|
||||||
|
assignee: null,
|
||||||
|
tags: ['spa', 'tasks'],
|
||||||
|
dueDate: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-06T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-08T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-4',
|
||||||
|
title: 'Other project task',
|
||||||
|
description: 'Used only to confirm mission filtering remains project-scoped',
|
||||||
|
status: 'not-started',
|
||||||
|
priority: 'low',
|
||||||
|
projectId: 'project-2',
|
||||||
|
missionId: 'mission-2',
|
||||||
|
assignee: null,
|
||||||
|
tags: null,
|
||||||
|
dueDate: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-06T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-06T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProjectDetailPage } from './project-detail';
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
apiMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||||
|
const routes: RouteObject[] = [
|
||||||
|
{ path: '/projects', element: <p>Projects index target</p> },
|
||||||
|
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
||||||
|
];
|
||||||
|
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickButtonByText(text: string): void {
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text),
|
||||||
|
);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`Button containing "${text}" not found`);
|
||||||
|
}
|
||||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectDetailPage', () => {
|
||||||
|
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||||
|
apiMock
|
||||||
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
|
expect(apiMock.mock.calls).toEqual([
|
||||||
|
['/api/projects/project-1'],
|
||||||
|
['/api/missions'],
|
||||||
|
['/api/tasks?projectId=project-1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Mosaic Stack');
|
||||||
|
expect(container.textContent).toContain('Route /projects/:id');
|
||||||
|
expect(container.textContent).toContain('Tasks');
|
||||||
|
expect(container.textContent).toContain('Done');
|
||||||
|
expect(container.textContent).toContain('Blocked');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('Missions (1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Ship web parity');
|
||||||
|
expect(container.textContent).not.toContain('Unrelated mission');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('PRD');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Mosaic Stack PRD');
|
||||||
|
expect(container.textContent).toContain('Ship the SPA route parity pages.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||||
|
apiMock
|
||||||
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('Tasks (3)');
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes('Route /projects'),
|
||||||
|
);
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('Read-only modal content should remain intact.');
|
||||||
|
|
||||||
|
const closeButton = container.querySelector('button[aria-label="Close task details"]');
|
||||||
|
expect(closeButton).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||||
|
apiMock
|
||||||
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
|
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Mosaic Stack');
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('Missions (0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('No missions for this project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
|
||||||
|
apiMock
|
||||||
|
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||||
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
|
const router = await renderProjectDetailPage();
|
||||||
|
|
||||||
|
const alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toBeTruthy();
|
||||||
|
expect(alert?.textContent).toContain('Project request failed');
|
||||||
|
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('Back to projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(router.state.location.pathname).toBe('/projects');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||||
|
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||||
|
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||||
|
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||||
|
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { getErrorMessage } from './page-errors';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||||
|
|
||||||
|
const projectStatusColors: Record<string, string> = {
|
||||||
|
active: 'bg-success/20 text-success',
|
||||||
|
paused: 'bg-warning/20 text-warning',
|
||||||
|
completed: 'bg-blue-600/20 text-blue-400',
|
||||||
|
archived: 'bg-gray-600/20 text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusColors: Record<string, string> = {
|
||||||
|
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||||
|
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||||
|
blocked: 'bg-error/20 text-error',
|
||||||
|
done: 'bg-success/20 text-success',
|
||||||
|
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
id: Tab;
|
||||||
|
label: string;
|
||||||
|
activeTab: Tab;
|
||||||
|
onClick: (tab: Tab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick(id)}
|
||||||
|
className={cn(
|
||||||
|
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||||
|
activeTab === id
|
||||||
|
? 'border-text-primary text-text-primary'
|
||||||
|
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailPage(): ReactElement {
|
||||||
|
const { id = '' } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
setError('Project id is missing.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
void Promise.all([
|
||||||
|
api<Project>('/api/projects/' + id),
|
||||||
|
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||||
|
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
|
||||||
|
])
|
||||||
|
.then(([loadedProject, allMissions, loadedTasks]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProject(loadedProject);
|
||||||
|
setMissions(allMissions.filter((mission) => mission.projectId === id));
|
||||||
|
setTasks(loadedTasks);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(getErrorMessage(caught, 'Failed to load project.'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Project</h1>
|
||||||
|
</header>
|
||||||
|
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Project</h1>
|
||||||
|
</header>
|
||||||
|
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
|
{error ?? 'Project not found.'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="mt-4 w-fit text-sm underline"
|
||||||
|
>
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTasks =
|
||||||
|
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
||||||
|
const prdContent = getPrdContent(project);
|
||||||
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||||
|
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||||
|
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
|
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-text-primary">{project.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2 py-0.5 text-xs',
|
||||||
|
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project.description ? (
|
||||||
|
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||||
|
{new Date(project.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||||
|
<StatCard
|
||||||
|
label="Done"
|
||||||
|
value={String(tasks.filter((task) => task.status === 'done').length)}
|
||||||
|
valueClass="text-success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="In Progress"
|
||||||
|
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
||||||
|
valueClass="text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Blocked"
|
||||||
|
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
||||||
|
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabButton
|
||||||
|
key={tab.id}
|
||||||
|
id={tab.id}
|
||||||
|
label={tab.label}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onClick={setActiveTab}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' ? (
|
||||||
|
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'tasks' ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<TaskStatusSummary
|
||||||
|
tasks={tasks}
|
||||||
|
activeFilter={taskFilter}
|
||||||
|
onFilterChange={setTaskFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
|
||||||
|
|
||||||
|
{activeTab === 'prd' && prdContent ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||||
|
<PrdViewer content={prdContent} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedTask ? (
|
||||||
|
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({
|
||||||
|
project,
|
||||||
|
missions,
|
||||||
|
tasks,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
missions: Mission[];
|
||||||
|
tasks: Task[];
|
||||||
|
}): ReactElement {
|
||||||
|
const recentTasks = [...tasks]
|
||||||
|
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||||
|
{recentTasks.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||||
|
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||||
|
{missions.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No missions yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{project.metadata && Object.keys(project.metadata).length > 0 ? (
|
||||||
|
<section className="lg:col-span-2">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||||
|
{JSON.stringify(project.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||||
|
<p className="text-xs text-text-muted">{label}</p>
|
||||||
|
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrdContent(project: Project): string | null {
|
||||||
|
if (!project.metadata) return null;
|
||||||
|
|
||||||
|
const prd = project.metadata['prd'];
|
||||||
|
if (typeof prd === 'string' && prd.trim().length > 0) {
|
||||||
|
return prd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prdContent = project.metadata['prdContent'];
|
||||||
|
if (typeof prdContent === 'string' && prdContent.trim().length > 0) {
|
||||||
|
return prdContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { projectFixtures } from './page-fixtures';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProjectsPage } from './projects';
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
apiMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||||
|
const routes: RouteObject[] = [
|
||||||
|
{ path: '/projects', element: <ProjectsPage /> },
|
||||||
|
{ path: '/projects/:id', element: <p>Project detail target</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectsPage', () => {
|
||||||
|
it('shows a visible loading state while the project request is in flight', async () => {
|
||||||
|
const deferred = createDeferred<typeof projectFixtures>();
|
||||||
|
apiMock.mockReturnValueOnce(deferred.promise);
|
||||||
|
|
||||||
|
await renderProjectsPage();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Loading projects...');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
deferred.resolve(projectFixtures);
|
||||||
|
await deferred.promise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders project cards from the API and navigates to a project detail route on click', async () => {
|
||||||
|
apiMock.mockResolvedValueOnce(projectFixtures);
|
||||||
|
|
||||||
|
const router = await renderProjectsPage();
|
||||||
|
|
||||||
|
expect(apiMock).toHaveBeenCalledWith('/api/projects');
|
||||||
|
expect(container.textContent).toContain('Mosaic Stack');
|
||||||
|
expect(container.textContent).toContain('Agent Runtime');
|
||||||
|
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes('Mosaic Stack'),
|
||||||
|
);
|
||||||
|
expect(button).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(router.state.location.pathname).toBe('/projects/project-1');
|
||||||
|
expect(container.textContent).toContain('Project detail target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when the API returns no projects', async () => {
|
||||||
|
apiMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await renderProjectsPage();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('No projects yet');
|
||||||
|
expect(container.textContent).toContain(
|
||||||
|
'Projects will appear here when created via the gateway API',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a visible alert when the projects request fails', async () => {
|
||||||
|
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||||
|
|
||||||
|
await renderProjectsPage();
|
||||||
|
|
||||||
|
const alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toBeTruthy();
|
||||||
|
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ProjectCard } from '@/components/projects/project-card';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Project } from '@/lib/types';
|
||||||
|
import { getErrorMessage } from './page-errors';
|
||||||
|
|
||||||
|
export function ProjectsPage(): ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void api<Project[]>('/api/projects')
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProjects(response);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(getErrorMessage(caught, 'Failed to load projects.'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
|
Projects will appear here when created via the gateway API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onClick={(selectedProject) => navigate(`/projects/${selectedProject.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { useSessionMock } = vi.hoisted(() => ({
|
||||||
|
useSessionMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
useSession: useSessionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { routes } from '@/routes';
|
||||||
|
|
||||||
|
function Boom(): never {
|
||||||
|
throw new Error('resource route render blew up');
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const cloned: RouteObject = { ...node };
|
||||||
|
if (cloned.path === path) {
|
||||||
|
cloned.element = <Boom />;
|
||||||
|
}
|
||||||
|
if (cloned.children) {
|
||||||
|
cloned.children = replaceRouteElementWithBoom(cloned.children, path);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
useSessionMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resource route error boundaries', () => {
|
||||||
|
it.each(['/projects', '/projects/:id', '/tasks'])(
|
||||||
|
'renders a recoverable fallback when %s throws during route render',
|
||||||
|
async (path) => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path;
|
||||||
|
const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), {
|
||||||
|
initialEntries: [initialEntry],
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(container.textContent).not.toBe('');
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ResourceRouteErrorBoundaryProps {
|
||||||
|
message: string;
|
||||||
|
href: string;
|
||||||
|
linkLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceRouteErrorBoundary({
|
||||||
|
message,
|
||||||
|
href,
|
||||||
|
linkLabel,
|
||||||
|
}: ResourceRouteErrorBoundaryProps): ReactElement {
|
||||||
|
useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
<a href={href} className="text-sm underline">
|
||||||
|
{linkLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsRouteErrorBoundary(): ReactElement {
|
||||||
|
return (
|
||||||
|
<ResourceRouteErrorBoundary
|
||||||
|
message="Something went wrong loading projects."
|
||||||
|
href="/projects"
|
||||||
|
linkLabel="Reload projects"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailRouteErrorBoundary(): ReactElement {
|
||||||
|
return (
|
||||||
|
<ResourceRouteErrorBoundary
|
||||||
|
message="Something went wrong loading this project."
|
||||||
|
href="/projects"
|
||||||
|
linkLabel="Back to projects"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TasksRouteErrorBoundary(): ReactElement {
|
||||||
|
return (
|
||||||
|
<ResourceRouteErrorBoundary
|
||||||
|
message="Something went wrong loading tasks."
|
||||||
|
href="/tasks"
|
||||||
|
linkLabel="Reload tasks"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { taskFixtures } from './page-fixtures';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TasksPage } from './tasks';
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
apiMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderTasksPage(): Promise<void> {
|
||||||
|
const routes: RouteObject[] = [{ path: '/tasks', element: <TasksPage /> }];
|
||||||
|
const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] });
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickButtonByText(text: string): void {
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text),
|
||||||
|
);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`Button containing "${text}" not found`);
|
||||||
|
}
|
||||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TasksPage', () => {
|
||||||
|
it('shows a visible loading state before the tasks request settles', async () => {
|
||||||
|
const deferred = createDeferred<typeof taskFixtures>();
|
||||||
|
apiMock.mockReturnValueOnce(deferred.promise);
|
||||||
|
|
||||||
|
await renderTasksPage();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Loading tasks...');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
deferred.resolve(taskFixtures);
|
||||||
|
await deferred.promise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => {
|
||||||
|
apiMock.mockResolvedValueOnce(taskFixtures);
|
||||||
|
|
||||||
|
await renderTasksPage();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Not Started');
|
||||||
|
expect(container.textContent).toContain('In Progress');
|
||||||
|
expect(container.textContent).toContain('Blocked');
|
||||||
|
|
||||||
|
const kanbanCard = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes('Route /projects/:id'),
|
||||||
|
);
|
||||||
|
expect(kanbanCard).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
container
|
||||||
|
.querySelector('button[aria-label="Close task details"]')
|
||||||
|
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clickButtonByText('List');
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes('Route /tasks'),
|
||||||
|
);
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a visible alert when the tasks request fails', async () => {
|
||||||
|
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||||
|
|
||||||
|
await renderTasksPage();
|
||||||
|
|
||||||
|
const alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toBeTruthy();
|
||||||
|
expect(alert?.textContent).toContain('Tasks request failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||||
|
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||||
|
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Task } from '@/lib/types';
|
||||||
|
import { getErrorMessage } from './page-errors';
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'kanban';
|
||||||
|
|
||||||
|
export function TasksPage(): ReactElement {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [view, setView] = useState<ViewMode>('kanban');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void api<Task[]>('/api/tasks')
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTasks(response);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(getErrorMessage(caught, 'Failed to load tasks.'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
|
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||||
|
<div className="flex rounded-lg border border-surface-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs transition-colors',
|
||||||
|
view === 'list'
|
||||||
|
? 'bg-surface-elevated text-text-primary'
|
||||||
|
: 'text-text-muted hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('kanban')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs transition-colors',
|
||||||
|
view === 'kanban'
|
||||||
|
? 'bg-surface-elevated text-text-primary'
|
||||||
|
: 'text-text-muted hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Kanban
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||||
|
) : view === 'kanban' ? (
|
||||||
|
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
|
||||||
|
) : (
|
||||||
|
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTask ? (
|
||||||
|
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import type { RouteObject } from 'react-router-dom';
|
|||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||||
|
import { ProjectsPage } from '@/spa/pages/projects';
|
||||||
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||||
return routeObjects.flatMap((route) => [
|
return routeObjects.flatMap((route) => [
|
||||||
@@ -67,4 +70,28 @@ describe('SPA route table', () => {
|
|||||||
expect(element.type).not.toBe(Placeholder);
|
expect(element.type).not.toBe(Placeholder);
|
||||||
expect(element.type).toBe(ChatPage);
|
expect(element.type).toBe(ChatPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/projects', ProjectsPage],
|
||||||
|
['/projects/:id', ProjectDetailPage],
|
||||||
|
['/tasks', TasksPage],
|
||||||
|
])(
|
||||||
|
'renders a real authenticated page instead of the P1 placeholder at %s',
|
||||||
|
(path, expectedType) => {
|
||||||
|
const element = findRoute(routes, path)?.element;
|
||||||
|
expect(isValidElement(element)).toBe(true);
|
||||||
|
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||||
|
expect(element.type).not.toBe(Placeholder);
|
||||||
|
expect(element.type).toBe(expectedType);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['/chat', '/projects', '/projects/:id', '/tasks'])(
|
||||||
|
'defines an error boundary for %s',
|
||||||
|
(path) => {
|
||||||
|
const route = findRoute(routes, path);
|
||||||
|
expect(route?.errorElement).toBeTruthy();
|
||||||
|
expect(isValidElement(route?.errorElement)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3100,
|
port: 3100,
|
||||||
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': gatewayTarget,
|
'/api': gatewayTarget,
|
||||||
'/socket.io': { target: gatewayTarget, ws: true },
|
'/socket.io': { target: gatewayTarget, ws: true },
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# CI Queue Guard Purpose Semantics
|
||||||
|
|
||||||
|
- **Issue:** #1146
|
||||||
|
- **Target branch:** `next`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`ci-queue-wait.sh` treats any result other than terminal success as asserted non-readiness. That is correct for merge readiness, but incorrect for the pre-push queue guard: a terminal failure or an empty status set means no pipeline is queued or running, so the queue is clear.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Make final-state handling purpose-sensitive while preserving the existing provider and payload safeguards:
|
||||||
|
|
||||||
|
- `--purpose push`
|
||||||
|
- wait while state is `pending`;
|
||||||
|
- return success for `terminal-success`, `terminal-failure`, and `no-status`;
|
||||||
|
- continue rejecting `malformed`, `unknown`, and unrecognized states.
|
||||||
|
- `--purpose merge`
|
||||||
|
- return success only for `terminal-success`;
|
||||||
|
- continue rejecting `terminal-failure`, `no-status`, malformed, unknown, and unrecognized states.
|
||||||
|
- `--require-status` remains authoritative: `no-status` fails for either purpose when it is supplied.
|
||||||
|
|
||||||
|
Diagnostics will explicitly distinguish a queue-clear push result from successful CI so callers cannot mistake an old failure for a green pipeline.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Extend the process-level tri-state regression harness with separate push and merge assertions:
|
||||||
|
|
||||||
|
1. Push passes for terminal success, terminal failure, and no status.
|
||||||
|
2. Push still fails for pending, malformed, and unknown states.
|
||||||
|
3. `--require-status` makes push/no-status fail.
|
||||||
|
4. Merge behavior remains fail-closed except for terminal success.
|
||||||
|
5. Existing provider-unavailable audit behavior remains unchanged.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# CI Queue Guard Purpose Semantics Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the pre-push CI queue guard pass when no pipeline is queued or running while preserving fail-closed merge readiness.
|
||||||
|
|
||||||
|
**Architecture:** Keep provider lookup and tri-state classification unchanged. Make only the final state dispatch purpose-sensitive: push treats valid non-pending states as queue-clear, while merge continues to require terminal success. Preserve `--require-status`, malformed-payload rejection, unknown-state rejection, and audited provider-unavailable behavior.
|
||||||
|
|
||||||
|
**Tech Stack:** Bash, process-level shell regression harnesses, Gitea/GitHub status APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Freeze Purpose-Specific State Semantics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
|
||||||
|
**Step 1: Add failing push assertions**
|
||||||
|
|
||||||
|
Change push expectations so `terminal-failure` and `no-status` require exit 0 plus an explicit `queue-clear` diagnostic. Add a `--require-status` assertion that keeps push/no-status non-zero.
|
||||||
|
|
||||||
|
**Step 2: Add failing merge assertions**
|
||||||
|
|
||||||
|
Invoke the same harness with `MOSAIC_TEST_PURPOSE=merge` and assert terminal failure and no status remain non-zero while terminal success remains zero.
|
||||||
|
|
||||||
|
**Step 3: Add unknown-state coverage**
|
||||||
|
|
||||||
|
Add a stub payload with a syntactically valid but unsupported status value and assert both purposes reject it.
|
||||||
|
|
||||||
|
**Step 4: Run the focused test and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: failures showing push terminal-failure and no-status returned exit 3 instead of exit 0 or lacked `queue-clear` diagnostics.
|
||||||
|
|
||||||
|
**Step 5: Commit the failing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
git commit -m "test(ci): define purpose-aware queue readiness"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Implement Purpose-Sensitive Final-State Dispatch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/ci-queue-wait.sh:458-481`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh`
|
||||||
|
|
||||||
|
**Step 1: Implement push queue-clear behavior**
|
||||||
|
|
||||||
|
For `no-status`, retain the existing `--require-status` failure. Otherwise, return success for push with an explicit diagnostic such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ci-queue-wait] queue-clear state=no-status purpose=push branch=<branch>; no queued or running CI.
|
||||||
|
```
|
||||||
|
|
||||||
|
For `terminal-failure`, return success only for push with the same queue-clear wording. Merge must continue returning asserted non-readiness.
|
||||||
|
|
||||||
|
**Step 2: Preserve malformed and unknown rejection**
|
||||||
|
|
||||||
|
Keep `malformed`, `unknown`, and unrecognized states non-zero for both purposes.
|
||||||
|
|
||||||
|
**Step 3: Run focused tests and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both scripts exit 0 and report their regression suites passed.
|
||||||
|
|
||||||
|
**Step 4: Commit implementation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||||
|
git commit -m "fix(ci): separate push queue clearance from merge readiness"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Verify, Review, and Document Evidence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/scratchpads/1146-ci-queue-purpose.md`
|
||||||
|
|
||||||
|
**Step 1: Run shell syntax and focused regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||||
|
bash -n packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run repository quality gates**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preflight
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every command exits 0.
|
||||||
|
|
||||||
|
**Step 3: Obtain independent review**
|
||||||
|
|
||||||
|
Request review of the exact branch head. Remediate all blocking findings and rerun focused and baseline gates.
|
||||||
|
|
||||||
|
**Step 4: Record evidence and commit**
|
||||||
|
|
||||||
|
Update the scratchpad with test output, review result, and residual risk, then commit it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/scratchpads/1146-ci-queue-purpose.md
|
||||||
|
git commit -m "docs(ci): record queue guard verification"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Keep the Merge Wrapper Aligned with the `next` Lane
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/pr-merge.sh:97-101`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh`
|
||||||
|
|
||||||
|
**Step 1: Write the failing regression**
|
||||||
|
|
||||||
|
Run the exact-head merge regression with its Gitea fixture targeting `next` and confirm the current wrapper rejects it because it only permits `main`.
|
||||||
|
|
||||||
|
**Step 2: Allow only documented integration targets**
|
||||||
|
|
||||||
|
Permit `main` and `next`; reject every other target. Do not alter exact-head pinning, queue-guard invocation, provider selection, or merge method enforcement.
|
||||||
|
|
||||||
|
**Step 3: Run focused merge regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass, including a Gitea merge fixture targeting `next`.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
||||||
|
git commit -m "fix(ci): allow reviewed merges into next"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Activate and Deliver Through `next`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Installed output: `~/.config/mosaic/tools/git/ci-queue-wait.sh`
|
||||||
|
|
||||||
|
**Step 1: Activate through the canonical installer**
|
||||||
|
|
||||||
|
From the reviewed worktree, run the framework installer in sync-only keep mode so operator files remain protected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||||
|
bash packages/mosaic/framework/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify installed/source parity**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmp -s \
|
||||||
|
packages/mosaic/framework/tools/git/ci-queue-wait.sh \
|
||||||
|
~/.config/mosaic/tools/git/ci-queue-wait.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
**Step 3: Run mandatory pre-push queue guard**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B fix/1146-ci-queue-purpose
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: branch-absent or queue-clear success.
|
||||||
|
|
||||||
|
**Step 4: Push and open a PR against `next`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin fix/1146-ci-queue-purpose
|
||||||
|
~/.config/mosaic/tools/git/pr-create.sh \
|
||||||
|
-t "fix(ci): make queue guard purpose-sensitive" \
|
||||||
|
-b "Closes #1146" \
|
||||||
|
-B next \
|
||||||
|
-H fix/1146-ci-queue-purpose \
|
||||||
|
-i 1146
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Complete reviewed integration**
|
||||||
|
|
||||||
|
Wait for exact-head terminal-green CI, obtain the required review, merge via the Mosaic wrapper, verify merged CI, and close #1146. Do not bypass any gate.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #1019 — Zero-timeout queue-guard harness race
|
||||||
|
|
||||||
|
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||||
|
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||||
|
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||||
|
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||||
|
2. Every case that intends status classification positively proves provider observation.
|
||||||
|
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||||
|
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||||
|
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||||
|
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||||
|
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||||
|
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||||
|
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||||
|
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||||
|
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||||
|
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||||
|
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||||
|
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||||
|
|
||||||
|
## Review remediation — semantic timeout vs. liveness bound
|
||||||
|
|
||||||
|
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||||
|
|
||||||
|
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||||
|
|
||||||
|
Remediation:
|
||||||
|
|
||||||
|
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||||
|
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||||
|
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||||
|
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||||
|
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||||
|
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||||
|
|
||||||
|
Post-review evidence:
|
||||||
|
|
||||||
|
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||||
|
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||||
|
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||||
|
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||||
|
|
||||||
|
## 60% context hold
|
||||||
|
|
||||||
|
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# #1146 — CI Queue Guard Purpose Semantics
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the pre-push queue guard wait for queued/running CI without requiring the previous remote head to have successful CI. Preserve fail-closed merge readiness.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/git/ci-queue-wait.sh`
|
||||||
|
- focused queue-guard regression tests
|
||||||
|
- design and scratchpad documentation
|
||||||
|
- local framework activation required before the fixed guard can authorize this branch's push
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Freeze purpose-specific behavior in failing process-level tests.
|
||||||
|
2. Implement the smallest state-dispatch change.
|
||||||
|
3. Run focused shell tests and repository quality gates.
|
||||||
|
4. Obtain independent review and remediate findings.
|
||||||
|
5. Install the reviewed framework source locally, run the mandatory pre-push queue guard, and push.
|
||||||
|
6. Open a PR against `next`, verify terminal-green CI, and close #1146 after merge.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- ASSUMPTION: no explicit token cap was provided.
|
||||||
|
- Working estimate: 12K tokens.
|
||||||
|
- Scope reduction: change only final-state dispatch and focused tests; do not redesign provider adapters.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- Confirmed source and installed guards are byte-identical.
|
||||||
|
- Reproduced `terminal-failure` blocking `--purpose push`.
|
||||||
|
- Root cause: final-state dispatch requires terminal success for both push and merge.
|
||||||
|
- Design approved: push is queue-clear on valid non-pending states; merge remains fail-closed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- RED confirmed before implementation: the focused tri-state harness reported push `terminal-failure` and `no-status` as `ASSERTED_NOT_READY`.
|
||||||
|
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` — all outcome classes passed.
|
||||||
|
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh` — 6/6 purpose-aware cases passed.
|
||||||
|
- GREEN: `bash -n` passed for the changed guard and both focused harnesses.
|
||||||
|
- GREEN: `pnpm preflight`, `pnpm typecheck`, and `pnpm lint` passed.
|
||||||
|
- `pnpm test` ran 45/46 workspace test tasks successfully, but the pre-existing Gateway `cross-user-isolation.test.ts` failed during cleanup with PostgreSQL error `28P01` (local `mosaic` password authentication failure). The changed Mosaic framework test task passed within that run.
|
||||||
|
- GREEN: focused queue and merge shell regressions passed after the wrapper change.
|
||||||
|
- GREEN: isolated Mosaic Vitest run passed (81 files, 1,514 tests).
|
||||||
|
- The normal parallel Mosaic Vitest run has an environment-sensitive pre-existing failure in `install-ordering-guard.spec.ts`: the real activation probe changes between two calls while other suites run concurrently. Running the same spec alone and the complete Vitest suite with one fork passes.
|
||||||
|
- The framework shell suite's pre-existing `version_coupling_unittest.py` also fails locally because the newly installed `mosaic` is now on PATH despite the test injecting a nonexistent PATH; CI's clean image does not have this global CLI. All changed queue/merge harnesses pass.
|
||||||
|
- GREEN: `pnpm format:check` passed.
|
||||||
|
- Note: an additional ad hoc Prettier command was not applicable to shell files because Prettier has no shell parser; the repository-wide format check passed using its configured file globs.
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
- Independent Codex review of the six-file diff: approved, confidence 0.84, zero blockers/should-fix/suggestions.
|
||||||
|
- Review confirmed push queue-clear behavior, merge fail-closed behavior, and `--require-status` coverage.
|
||||||
|
|
||||||
|
## Risks and Blockers
|
||||||
|
|
||||||
|
- Canonical framework activation completed with `MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 bash packages/mosaic/framework/install.sh`.
|
||||||
|
- Source and installed queue guards are byte-identical (`cmp` and SHA-256 parity passed).
|
||||||
|
- The installed pre-push guard now passes for the not-yet-remote feature branch with `queue clear`.
|
||||||
|
- The required merge wrapper then exposed a second bootstrap defect: `pr-merge.sh` hardcoded `main`, contradicting the documented PR-based `next` integration lane. Tracked as #1149 and fixed in the same delivery branch with a regression fixture targeting `next`.
|
||||||
|
- Activation emitted the existing manifest-safety warning that six `fleet/run/*.hb*` operator files were touched then restored; no data loss was observed, but this remains a pre-existing framework-manifest defect to report separately.
|
||||||
|
- The first activation attempt timed out after 600 seconds while copying the 113K-file operator snapshot; the bounded 1,800-second retry completed successfully. It left a partial durable snapshot from the interrupted attempt in the normal backup directory; the completed snapshot is the newer `pre-update-20260810T195317Z` entry.
|
||||||
|
- Full baseline test completion is blocked by the unrelated local PostgreSQL authentication/cleanup failure described above; CI has its own disposable PostgreSQL service.
|
||||||
|
- Existing `.mosaic/orchestrator/*` working-tree changes are unrelated and must remain unstaged.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# W-B — Measure Pi's real tool registry
|
||||||
|
|
||||||
|
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
|
||||||
|
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
|
||||||
|
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
|
||||||
|
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
|
||||||
|
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
|
||||||
|
--no-context-files --no-skills --no-prompt-templates --no-extensions \
|
||||||
|
-e /tmp/measure-pi-tool-registry.ts \
|
||||||
|
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
|
||||||
|
|
||||||
|
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
|
||||||
|
|
||||||
|
## Exact distinct built-in set
|
||||||
|
|
||||||
|
The installed Pi built-in registry is exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{bash, edit, find, grep, ls, read, write}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
|
||||||
|
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
|
||||||
|
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
|
||||||
|
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
|
||||||
|
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
|
||||||
|
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
|
||||||
|
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
|
||||||
|
|
||||||
|
The raw distinct `event.toolName` result was:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["bash", "edit", "find", "grep", "ls", "read", "write"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
|
||||||
|
|
||||||
|
## Positive control
|
||||||
|
|
||||||
|
The known `read` tool was the control. The method surfaced it twice:
|
||||||
|
|
||||||
|
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
|
||||||
|
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
|
||||||
|
|
||||||
|
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
|
||||||
|
|
||||||
|
## Carve-out comparison and collision result
|
||||||
|
|
||||||
|
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
|
||||||
|
|
||||||
|
- `read`: real built-in.
|
||||||
|
- `grep`: real built-in.
|
||||||
|
- `find`: real built-in.
|
||||||
|
- `ls`: real built-in.
|
||||||
|
|
||||||
|
All four carve-out names are exact, case-sensitive Pi tool names.
|
||||||
|
|
||||||
|
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
|
||||||
|
```
|
||||||
|
|
||||||
|
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
|
||||||
|
|
||||||
|
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- `pi --version` → `0.84.1`.
|
||||||
|
- Isolated probe exit → `0`.
|
||||||
|
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
|
||||||
|
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
|
||||||
|
- Hook counts → exactly one call for each of the seven names.
|
||||||
|
- Mutation artifact after `write` then `edit` → exact content `after`.
|
||||||
|
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
|
||||||
|
|
||||||
|
## Risks / limitations
|
||||||
|
|
||||||
|
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
|
||||||
|
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PR merge squash message field
|
||||||
|
|
||||||
|
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Branch:** `fix/pr-merge-message-field`
|
||||||
|
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||||
|
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||||
|
|
||||||
|
## Binding requirements
|
||||||
|
|
||||||
|
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||||
|
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||||
|
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||||
|
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||||
|
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||||
|
|
||||||
|
## Derived interface decisions
|
||||||
|
|
||||||
|
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||||
|
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||||
|
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||||
|
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||||
|
|
||||||
|
## Canonical delivery plan
|
||||||
|
|
||||||
|
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||||
|
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||||
|
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||||
|
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||||
|
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||||
|
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||||
|
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||||
|
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||||
|
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||||
|
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||||
|
|
||||||
|
## Remediation and current review state
|
||||||
|
|
||||||
|
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||||
|
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||||
|
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||||
|
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||||
|
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||||
|
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||||
|
|
||||||
|
## Disposable provider fixture acceptance
|
||||||
|
|
||||||
|
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||||
|
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||||
|
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||||
|
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||||
|
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||||
|
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||||
|
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||||
|
|
||||||
|
## Fixture preflight
|
||||||
|
|
||||||
|
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||||
|
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||||
|
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||||
|
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||||
|
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||||
|
|
||||||
|
## Fixture result
|
||||||
|
|
||||||
|
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||||
|
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||||
|
- Trailer-shaped lines, verbatim and in order:
|
||||||
|
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||||
|
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||||
|
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||||
|
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||||
|
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||||
|
|
||||||
|
## Current hold point
|
||||||
|
|
||||||
|
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||||
|
|
||||||
|
## Security review 96 remediation
|
||||||
|
|
||||||
|
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||||
|
|
||||||
|
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||||
|
|
||||||
|
Security remediation:
|
||||||
|
|
||||||
|
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||||
|
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||||
|
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||||
|
|
||||||
|
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# P3-R1 — Routing Health Enum + `/mcp` Wiring Scratchpad
|
||||||
|
|
||||||
|
**Task:** P3 hands-on acceptance blockers #1 and #5
|
||||||
|
**Mission:** `mvp-20260312` (active)
|
||||||
|
**Branch:** `feat/webui-p3r1-routing-mcp` from `origin/next`
|
||||||
|
**Required base:** `20718b5a273d243363a4f5cbef5bbf692a805bdb`
|
||||||
|
**Tracking ref:** Direct P3-R1 author brief; no provider issue supplied; no PR or merge authorized
|
||||||
|
**Started:** 2026-08-11T14:52:52-05:00
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix exactly two P3 acceptance blockers:
|
||||||
|
|
||||||
|
1. Make routing consume the canonical `ProviderHealthStatus` enum, with `healthy` and `degraded` routable and `down` non-routable, at both routing decision sites.
|
||||||
|
2. Wire `McpClientModule` into `CommandsModule`, make `McpClientService` required, remove the unreachable unavailable-service branch, and prove `/mcp status` reaches the client.
|
||||||
|
|
||||||
|
Explicitly excluded: provider registry/adapters, `provider.service.ts`, `agent.service.ts`, `chat.gateway.ts`, fallback membership, task classification, selector UI, conversation resume, reload UX, WS/origin/handshake behavior, dependencies/lockfile, and `apps/web/**` changes.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Confirm exact base/branch and inspect every cited source/test anchor plus all direct `CommandExecutorService` construction sites.
|
||||||
|
2. Record baseline gateway and focused routing/commands/MCP test totals.
|
||||||
|
3. Add regression tests first and run focused tests to capture expected RED failures.
|
||||||
|
4. Apply only the typed routing helper/signature changes and required MCP module/constructor/guard changes; update impossible `up` fixtures.
|
||||||
|
5. Run focused tests, all user-required verification gates, lockfile/scope/diff checks, and record counts.
|
||||||
|
6. Obtain independent spec and code/security review; remediate any findings and repeat affected gates.
|
||||||
|
7. Commit conventionally, run the pre-push queue guard, and push only the feature branch (no PR or merge).
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap supplied. Working soft cap: **30K tokens**, based on two bounded gateway bug fixes, focused TDD, full gateway/root verification, independent review, and branch delivery. One coding worker will execute serially; reviews will be independent and serial to avoid worktree collisions.
|
||||||
|
|
||||||
|
## Startup Evidence
|
||||||
|
|
||||||
|
- `git fetch origin` rc=0.
|
||||||
|
- `origin/next` confirmed exactly `20718b5a273d243363a4f5cbef5bbf692a805bdb`.
|
||||||
|
- Local and remote `feat/webui-p3r1-routing-mcp` were absent before creation.
|
||||||
|
- Branch creation rc=0; HEAD equals the required base.
|
||||||
|
- Harness-owned `.mosaic/orchestrator/session.lock` was already dirty and remains excluded from staging/commit.
|
||||||
|
|
||||||
|
## Baseline Evidence
|
||||||
|
|
||||||
|
- Gateway full suite: rc=0; **64 files / 693 tests passed**, 7 files / 17 tests skipped (71 files / 710 tests total).
|
||||||
|
- Routing sub-suite (`src/agent/routing`): rc=0; **3 files / 105 tests passed**.
|
||||||
|
- Commands/MCP sub-suite (`src/commands`, `src/mcp-client`): rc=0; **6 files / 76 tests passed**.
|
||||||
|
|
||||||
|
## TDD and Implementation Evidence
|
||||||
|
|
||||||
|
- Routing RED: after canonical fixture/test changes but before the service fix, focused routing run returned rc=1 with **15 failed / 91 passed (106)** because the old `up`/`ok` gates rejected `healthy` and `degraded`.
|
||||||
|
- Routing GREEN: canonical `ProviderHealthStatus` map types, one `isRoutable` helper, and both comparison sites corrected; focused routing suite passed.
|
||||||
|
- MCP behavior test now drives `/mcp status` through a required mock client and asserts the client is called, success is returned for zero servers, and the former unavailable message is absent.
|
||||||
|
- MCP wiring mutation RED used the final `Reflect.getMetadata('imports', CommandsModule)` assertion with only the production `McpClientModule` import/registration temporarily removed: rc=1, exact failure `expected [GCModule, …] to include McpClientModule`.
|
||||||
|
- MCP wiring GREEN after byte-for-byte production restoration: rc=0. Temporary mutation did not remain.
|
||||||
|
- Every direct `new CommandExecutorService(...)` test construction now supplies a non-null MCP client mock.
|
||||||
|
- Initial Codex worker launch failed rc=1 from missing OpenAI bearer authentication. First Mosaic Claude launch failed rc=1 because Distrobox-local runtime contracts were absent; retry with the supported host `MOSAIC_HOME=/home/jwoltje/.config/mosaic` succeeded.
|
||||||
|
|
||||||
|
## Review Evidence
|
||||||
|
|
||||||
|
- Independent spec review: **approve**, 0 blockers, scope OK.
|
||||||
|
- Independent code/security review: **approve**, 0 blockers, 0 critical/high security findings. It suggested an actual module-wiring assertion, which was added with valid mutation RED/GREEN evidence.
|
||||||
|
- Independent final re-review after remediation: **approve**, 0 blockers, 0 critical/high security findings, no remaining findings.
|
||||||
|
- Optional missing-provider/`undefined` test suggestion was not adopted: the brief explicitly requires the three canonical statuses (`healthy`, `degraded`, `down`) and forbids scope expansion; runtime behavior for absent keys remains `undefined` → non-routable through the required helper signature.
|
||||||
|
|
||||||
|
## Documentation Assessment
|
||||||
|
|
||||||
|
- `docs/PRD.md` already requires provider fallback/routing and MCP capability; this increment restores implementation to those existing contracts.
|
||||||
|
- No public API endpoint, payload schema, auth/permission rule, navigation, deployment procedure, or new user workflow changes. OpenAPI, endpoint index, user/admin/developer guides, and sitemap are therefore N/A for this bounded repair.
|
||||||
|
- This append-only scratchpad is the implementation, TDD, review, and verification record. Canonical docs remain in-repo; no publishing action is in scope.
|
||||||
|
|
||||||
|
## Final Verification Evidence
|
||||||
|
|
||||||
|
All required and repository-situational gates completed with rc=0:
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm install --frozen-lockfile` | rc=0 |
|
||||||
|
| Gateway typecheck | rc=0 |
|
||||||
|
| Gateway lint | rc=0 |
|
||||||
|
| Routing focused suite | rc=0; 3 files / 106 tests |
|
||||||
|
| Commands/MCP focused suite | rc=0; 6 files / 78 tests |
|
||||||
|
| Gateway full suite | rc=0; 64 files / 696 tests passed; 7 files / 17 tests skipped |
|
||||||
|
| Gateway build | rc=0 |
|
||||||
|
| Root typecheck | rc=0; 45/45 tasks |
|
||||||
|
| Web test | rc=0; 19 files / 154 tests |
|
||||||
|
| Root lint | rc=0; 25/25 tasks |
|
||||||
|
| Root format check | rc=0 |
|
||||||
|
| `git diff --check` | rc=0 |
|
||||||
|
|
||||||
|
- Gateway suite before→after: **693→696 passing tests**; skipped remained 17 (total 710→713).
|
||||||
|
- Routing focused before→after: **105→106 passing tests**.
|
||||||
|
- Commands/MCP focused before→after: **76→78 passing tests**.
|
||||||
|
- `pnpm-lock.yaml` SHA-256 before/after frozen install: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; diff versus `origin/next` rc=0.
|
||||||
|
- Verified no changed path under `apps/web/**`, provider service/adapters, `agent.service.ts`, `chat.gateway.ts`, or lockfile.
|
||||||
|
- Verified both `McpClientModule` production wiring lines remain and no impossible `up`/`ok` routing status checks/fixtures remain.
|
||||||
|
- Verified code/test diff SHA-256: `27b41a855084b9dd85a7bc2a79fa3251d114ee226a4f1b835e65134c25a4f5a8`.
|
||||||
|
|
||||||
|
## Delivery State
|
||||||
|
|
||||||
|
Implementation, testing, documentation assessment, and independent review are complete. Remaining authorized actions: format this final scratchpad append, create one conventional commit, run the required push queue guard, and push only `feat/webui-p3r1-routing-mcp`; no PR or merge.
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# WebUI Phase P — P4-1 Projects + Tasks SPA Scratchpad
|
||||||
|
|
||||||
|
**Task ID:** P4-1
|
||||||
|
**Tracking ref:** Phase P RFC §6.4 / §2.4 author brief; no provider issue supplied
|
||||||
|
**Mission context:** `mvp-20260312` (active)
|
||||||
|
**Branch:** `feat/webui-p4-1` from `origin/next`
|
||||||
|
**Started:** 2026-08-11
|
||||||
|
**Role:** orchestrator-controlled author worker; `docs/TASKS.md` remains orchestrator-only and is not part of this increment
|
||||||
|
|
||||||
|
## Original tasking
|
||||||
|
|
||||||
|
Implement the bounded P4-1 SPA parity slice exactly as briefed: real authenticated `/projects`, `/projects/:id`, and `/tasks` React Router pages ported from the existing Next baseline; reuse existing project/task components; preserve relative same-origin REST access; support only the existing project/task PATCH edit flows; add route error boundaries and page tests; pin Vite strict port 3100 and legacy Next dev/start to 3101; make no gateway, package, dependency, nav-shell, chat, settings, or admin changes; verify, commit, and push only `feat/webui-p4-1` (no PR or merge). If any required REST endpoint is absent, stop as `BLOCKED:` rather than inventing a workaround.
|
||||||
|
|
||||||
|
## Objective and acceptance map
|
||||||
|
|
||||||
|
- A: `/projects` list with loading, cards, empty, and surfaced API-error states; defer MissionStatus side panel.
|
||||||
|
- B: `/projects/:id` detail with overview/tasks/missions tabs, parallel project/mission/task load, bounded project and task PATCH edits.
|
||||||
|
- C: `/tasks` list/kanban with bounded task PATCH edits.
|
||||||
|
- D: replace the three route placeholders and add route error boundaries without changing chat.
|
||||||
|
- E: use only `@/lib/types` and `@/lib/api`; relative `/api/...` REST paths only.
|
||||||
|
- F: Vite `strictPort: true`; Next dev/start pinned to 3101; no proxy/dependency changes.
|
||||||
|
- G: fixture-backed Vitest coverage for lists, states, tabs/toggles, PATCH calls, and real route elements; all named verification gates pass.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Verify the required gateway REST routes and inspect the exact `origin/next` SPA, Next baseline pages, shared components, types, API helper, and P3 test conventions.
|
||||||
|
2. Record the pre-change web test count and add required page/route tests first, observing expected RED failures.
|
||||||
|
3. Port the three pages and shared route error boundary, then wire routes and bounded PATCH flows.
|
||||||
|
4. Apply only the two dev-port pin changes.
|
||||||
|
5. Run focused tests, all user-required gates, lockfile and same-origin checks, and `git diff --check`.
|
||||||
|
6. Obtain independent spec/code/security review; remediate and repeat affected gates until clear.
|
||||||
|
7. Commit conventionally, run the required pre-push queue guard, push only the feature branch, and record exact evidence here.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
TDD is applied because this adds user-visible data/edit behavior. Component tests mock only the existing API boundary and exercise rendered behavior and PATCH payloads. Primary situational evidence is the required page interaction suite plus route-resolution checks; baseline evidence is typecheck, lint, full web test, build, frozen install, formatting/diff hygiene, and same-origin grep.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap was supplied. Working soft cap: **50K tokens**, derived from three coupled React pages, route/error wiring, interaction tests, port config, review/remediation, and full verification. One Codex implementation worker and independent review workers will be used serially to avoid worktree collisions.
|
||||||
|
|
||||||
|
## Base evidence
|
||||||
|
|
||||||
|
- `git fetch origin` rc=0.
|
||||||
|
- `origin/next` and branch start: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||||
|
- Branch created: `feat/webui-p4-1` tracking `origin/next`.
|
||||||
|
- Harness-owned `.mosaic/orchestrator/session.lock` is dirty and must remain unstaged/uncommitted.
|
||||||
|
|
||||||
|
## Progress / evidence
|
||||||
|
|
||||||
|
- [x] Loaded active mission manifest, latest scratchpad, top-level tasks, PRD, orchestration/delivery/frontend/QA/documentation/review/TypeScript guides, and matching implementation skills.
|
||||||
|
- [x] Confirmed exact base SHA and created the feature branch.
|
||||||
|
- [x] Required REST endpoints verified in Gateway source: project list/detail/PATCH, task list/filter/detail/PATCH, and mission list all exist.
|
||||||
|
- [x] Scope assumption check found a blocking contradiction before source implementation.
|
||||||
|
- [ ] Pre-change web test count recorded.
|
||||||
|
- [ ] RED tests observed.
|
||||||
|
- [ ] Implementation complete.
|
||||||
|
- [ ] Independent review clear.
|
||||||
|
- [x] Required verification gates run against the unchanged web baseline; exact leak-grep expectation is independently blocked by 12 pre-existing matches.
|
||||||
|
- [x] Blocker record committed as `5ede86a5` and feature branch pushed; no PR opened and no merge performed.
|
||||||
|
|
||||||
|
## Blocker — 2026-08-11
|
||||||
|
|
||||||
|
`P4-1` is blocked because the bounded edit UX asserted by the brief does not exist at the confirmed `origin/next` base (`e00cc475`):
|
||||||
|
|
||||||
|
- `apps/web/src/components/tasks/task-detail-modal.tsx` is read-only. Its props are only `task` and `onClose`; it contains no input/select/textarea, update callback, or `api()`/PATCH call.
|
||||||
|
- `apps/web/src/app/(dashboard)/projects/[id]/page.tsx` contains no project edit controls and no project/task PATCH call.
|
||||||
|
- `apps/web/src/app/(dashboard)/tasks/page.tsx` does not open `TaskDetailModal`; its click handler only logs that a future detail view will be added.
|
||||||
|
- A repository grep over those reference/component paths finds zero `method: 'PATCH'` calls and zero edit/save form controls.
|
||||||
|
- The required leak grep also disproves the brief's baseline claim that `apps/web/src` is clean: the exact command returns rc=0 with 12 pre-existing matches at `origin/next` (external font links, an avatar placeholder, an SVG namespace, and test fixtures). There are zero new matches in the worktree, but making the exact grep empty would require unrelated changes the brief forbids.
|
||||||
|
|
||||||
|
The brief simultaneously requires tests proving task/project PATCH edits and forbids inventing edit UX, fields, or flows beyond what those references expose. Adding controls or callback contracts would therefore be new design outside the authorized port/wiring scope. Per the explicit stop rule, no SPA source, route, config, package, lockfile, or test implementation was started.
|
||||||
|
|
||||||
|
## Verification evidence on unchanged web baseline
|
||||||
|
|
||||||
|
These gates were still run to distinguish the brief contradiction from a broken base:
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
|
||||||
|
- `pnpm --filter @mosaicstack/web lint` — rc=0.
|
||||||
|
- `pnpm --filter @mosaicstack/web test` — rc=0; 15 files, 133 tests passed. Before→after remains 133→133 because implementation was halted.
|
||||||
|
- `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated.
|
||||||
|
- `pnpm install --frozen-lockfile` — rc=0; lock SHA-256 remained `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; `git diff` for the lockfile rc=0.
|
||||||
|
- `git grep -nE "http://|https://" -- apps/web/src` — rc=0 with 12 pre-existing matches; `origin/next` count=12, current HEAD count=12, new worktree additions=0.
|
||||||
|
- `pnpm format:check` — rc=0.
|
||||||
|
- `git diff --check` — rc=0.
|
||||||
|
|
||||||
|
## Delivery evidence
|
||||||
|
|
||||||
|
- Initial queue-guard invocation through shell `~` failed rc=127 because this Distrobox resolves `HOME` to `/home/jwoltje/distrobox-homes/mosaic-dev`, where the injected fleet status already reports the tools installation missing.
|
||||||
|
- Correct supported host-tool invocation `/home/jwoltje/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B feat/webui-p4-1` — rc=0; branch absent remotely and queue clear.
|
||||||
|
- `git push -u origin feat/webui-p4-1` — rc=0. The push hook additionally ran repository preflight, typecheck (45/45 tasks), lint (25/25 tasks), and format check successfully.
|
||||||
|
- No PR was opened and no merge was attempted, per the brief.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- The active harness mutates `.mosaic/orchestrator/session.lock`; it is excluded from staging.
|
||||||
|
- The Phase P §6.4 prose is absent from this checkout, so the brief-named route spec and existing Next pages are the bounded implementation anchors.
|
||||||
|
- The MissionStatus `/api/coord/status` panel is explicitly deferred to a follow-up and must not enter P4-1.
|
||||||
|
|
||||||
|
## REV 2 continuation — 2026-08-10T21:59:17-05:00
|
||||||
|
|
||||||
|
REV 2 supersedes the original tasking above. The independent check confirmed that the Next reference pages and `task-detail-modal.tsx` are read-only, so project/task editing is deliberately deferred to P4-1b. Do not re-litigate or implement the former PATCH requirements.
|
||||||
|
|
||||||
|
### Revised objective and acceptance map
|
||||||
|
|
||||||
|
- A: `/projects` read-only SPA list with loading, cards, empty, and surfaced API-error states; card navigation to `/projects/:id`; no MissionStatus panel.
|
||||||
|
- B: `/projects/:id` read-only SPA detail using `useParams`, `useNavigate`, and the specified three-request `Promise.all`; preserve reference overview/tasks/missions tabs and read-only task modal.
|
||||||
|
- C: `/tasks` read-only SPA list/kanban view with the existing read-only task modal.
|
||||||
|
- D: replace only the three route placeholders and add page-local route error boundaries without modifying chat.
|
||||||
|
- E: use only `@/lib/types` and `@/lib/api` with relative `/api/...` REST paths.
|
||||||
|
- F: add Vite `strictPort: true` and pin legacy Next dev/start to 3101 without dependency or proxy changes.
|
||||||
|
- G: add fixture-backed page tests and route-resolution assertions; run every user-specified verification gate and the narrowed new-code origin check.
|
||||||
|
|
||||||
|
### Revised plan
|
||||||
|
|
||||||
|
1. Reset `feat/webui-p4-1` to the exact `origin/next` base and independently verify the read-only reference/component/API assumptions.
|
||||||
|
2. Record the pre-change web suite count; write and run focused page/route tests first to observe expected RED failures.
|
||||||
|
3. Port the three read-only pages, add independent route error boundaries, wire routes, and apply only the two dev-port pins.
|
||||||
|
4. Run focused tests, full required web gates, frozen install/lockfile proof, origin check, format/diff hygiene, and accessibility/state-transition sanity checks.
|
||||||
|
5. Obtain independent spec/code/security review, remediate every blocker, and repeat affected gates.
|
||||||
|
6. Commit conventionally, run the supported pre-push queue guard, and push only `feat/webui-p4-1`; no PR and no merge.
|
||||||
|
|
||||||
|
### Revised budget and session state
|
||||||
|
|
||||||
|
- No explicit token cap was supplied. Working soft cap remains **50K tokens**.
|
||||||
|
- TDD is required by the user and frontend skill for the new SPA behavior; tests must fail for missing pages before production implementation.
|
||||||
|
- Documentation assessment: this is a parity port of existing read-only behavior, not a new public workflow or API contract; the task scratchpad is the required delivery record, with no user/developer/API documentation changes in this bounded increment.
|
||||||
|
- Exact base confirmed after fresh fetch/reset: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||||
|
- Remote `feat/webui-p4-1` is absent; the eventual push is a fresh branch creation.
|
||||||
|
- Harness-owned `.mosaic/orchestrator/session.lock` remains excluded from staging.
|
||||||
|
|
||||||
|
## REV 2 implementation pass — 2026-08-11T22:05:00Z
|
||||||
|
|
||||||
|
### Startup verification
|
||||||
|
|
||||||
|
- Loaded required startup files: `/home/jwoltje/.config/mosaic/CONSTITUTION.md`, `/home/jwoltje/.config/mosaic/SOUL.md`, project `AGENTS.md`, `/home/jwoltje/.config/mosaic/guides/E2E-DELIVERY.md`, `docs/PRD.md`, and this scratchpad.
|
||||||
|
- Loaded required skills: `test-driven-development`, `vitest`, `vite`, `next-best-practices`, and `verification-before-completion`.
|
||||||
|
- Loaded required runtime guide: `/home/jwoltje/.config/mosaic/runtime/codex/RUNTIME.md`.
|
||||||
|
- Structured reasoning tool availability verified through the harness before planning.
|
||||||
|
- `git rev-parse HEAD` confirmed exact required base: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||||
|
- `git status --short` at startup showed only the expected untracked append-only scratchpad; `.mosaic/orchestrator/session.lock` did not appear and must remain unstaged if it changes later.
|
||||||
|
|
||||||
|
### Current plan
|
||||||
|
|
||||||
|
1. Record baseline evidence.
|
||||||
|
2. Add focused failing SPA page/route specs first.
|
||||||
|
3. Run focused RED command and record the missing-page failure.
|
||||||
|
4. Implement the bounded read-only SPA pages, boundaries, route wiring, and the two dev-port pins.
|
||||||
|
5. Run the required verification matrix and inspect diff hygiene.
|
||||||
|
|
||||||
|
### Pre-change baseline
|
||||||
|
|
||||||
|
- Command: `pnpm --filter @mosaicstack/web test`
|
||||||
|
- rc=0
|
||||||
|
- File/test totals before changes: `15 files / 133 tests`
|
||||||
|
- Notes: baseline already includes `/chat` SPA route coverage and raw React `createRoot`/`act` page specs that this pass should mirror.
|
||||||
|
|
||||||
|
### RED evidence before production implementation
|
||||||
|
|
||||||
|
- Command: `pnpm --filter @mosaicstack/web test -- src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
|
||||||
|
- rc=1
|
||||||
|
- Expected missing-feature reason confirmed:
|
||||||
|
- `src/spa/pages/projects.spec.tsx`, `src/spa/pages/project-detail.spec.tsx`, `src/spa/pages/tasks.spec.tsx`, and `src/spa/routes.spec.tsx` fail import resolution because the SPA page modules do not exist yet.
|
||||||
|
- `src/spa/pages/resource-route-boundaries.spec.tsx` fails because the current `/projects`, `/projects/:id`, and `/tasks` routes still render placeholders without route-level alert fallbacks.
|
||||||
|
|
||||||
|
### Implementation summary
|
||||||
|
|
||||||
|
- Added SPA pages:
|
||||||
|
- `apps/web/src/spa/pages/projects.tsx`
|
||||||
|
- `apps/web/src/spa/pages/project-detail.tsx`
|
||||||
|
- `apps/web/src/spa/pages/tasks.tsx`
|
||||||
|
- Added page-local route boundary components in `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`.
|
||||||
|
- Wired `/projects`, `/projects/:id`, and `/tasks` in `apps/web/src/routes.tsx` with real elements and `errorElement`s.
|
||||||
|
- Added fixture-backed raw React Vitest coverage plus route assertions for the three pages and their boundaries.
|
||||||
|
- Applied only the requested dev topology pins:
|
||||||
|
- `apps/web/vite.config.ts`: `server.strictPort = true`
|
||||||
|
- `apps/web/package.json`: `next dev -p 3101`, `next start -p 3101`
|
||||||
|
|
||||||
|
### Post-implementation verification
|
||||||
|
|
||||||
|
- Focused changed specs:
|
||||||
|
- Command: `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
|
||||||
|
- rc=0
|
||||||
|
- Result: `5 files / 26 tests` passed.
|
||||||
|
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
|
||||||
|
- `pnpm --filter @mosaicstack/web lint` — rc=0.
|
||||||
|
- `pnpm --filter @mosaicstack/web test` — rc=0; post-change totals `19 files / 153 tests`.
|
||||||
|
- `pnpm --filter @mosaicstack/web build` — rc=1.
|
||||||
|
- Limitation: Next/Turbopack hit a sandbox/runtime failure while processing `apps/web/src/app/globals.css`: `creating new process`, `binding to a port`, `Operation not permitted (os error 1)`. This appears environmental, not route-code-specific.
|
||||||
|
- `pnpm --filter @mosaicstack/web build:vite` — rc=0.
|
||||||
|
- `pnpm install --frozen-lockfile` — rc=1.
|
||||||
|
- Limitation: repo `prepare` hook attempted to lock `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack/.git/config`, which is read-only in this harness.
|
||||||
|
- `pnpm-lock.yaml` SHA-256 before/after install attempt: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
|
||||||
|
- `origin/next` `pnpm-lock.yaml` SHA-256: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
|
||||||
|
- `git diff -- pnpm-lock.yaml` — rc=0 (unchanged).
|
||||||
|
- `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty, as required).
|
||||||
|
- `pnpm format:check` — rc=0.
|
||||||
|
- `git diff --check` — rc=0.
|
||||||
|
|
||||||
|
### Final worktree check
|
||||||
|
|
||||||
|
- `git status --short` shows only the authorized web files plus this scratchpad.
|
||||||
|
- `.mosaic/orchestrator/session.lock` remains unstaged.
|
||||||
|
- Current changed file set:
|
||||||
|
- `apps/web/package.json`
|
||||||
|
- `apps/web/src/routes.tsx`
|
||||||
|
- `apps/web/src/spa/routes.spec.tsx`
|
||||||
|
- `apps/web/vite.config.ts`
|
||||||
|
- `apps/web/src/spa/pages/page-errors.ts`
|
||||||
|
- `apps/web/src/spa/pages/page-fixtures.ts`
|
||||||
|
- `apps/web/src/spa/pages/project-detail.spec.tsx`
|
||||||
|
- `apps/web/src/spa/pages/project-detail.tsx`
|
||||||
|
- `apps/web/src/spa/pages/projects.spec.tsx`
|
||||||
|
- `apps/web/src/spa/pages/projects.tsx`
|
||||||
|
- `apps/web/src/spa/pages/resource-route-boundaries.spec.tsx`
|
||||||
|
- `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`
|
||||||
|
- `apps/web/src/spa/pages/tasks.spec.tsx`
|
||||||
|
- `apps/web/src/spa/pages/tasks.tsx`
|
||||||
|
- `docs/scratchpads/webui-p4-1.md`
|
||||||
|
|
||||||
|
## P4-1 REV 2 Final Delivery Verification — 2026-08-10T22:23:00Z
|
||||||
|
|
||||||
|
### Spec/Security Review Verdicts (Independent Reviews)
|
||||||
|
|
||||||
|
Both independent reviews cleared P4-1 REV 2 implementation without blockers:
|
||||||
|
- **Spec review:** approved; 0 blockers, 0 should-fix findings
|
||||||
|
- **Code/security review:** approved; 0 blockers, 0 critical/high findings, 0 should-fix recommendations
|
||||||
|
|
||||||
|
### Final Fresh Gate Verification (This Session)
|
||||||
|
|
||||||
|
All verification gates executed sequentially with rc=0 (except where noted):
|
||||||
|
|
||||||
|
1. `pnpm --filter @mosaicstack/web typecheck` — rc=0
|
||||||
|
2. `pnpm --filter @mosaicstack/web lint` — rc=0
|
||||||
|
3. `pnpm --filter @mosaicstack/web test` — rc=0; **19 files / 153 tests** (baseline: 15 files / 133 tests; added: 4 files / 20 tests)
|
||||||
|
4. `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated
|
||||||
|
5. `pnpm --filter @mosaicstack/web build:vite` — rc=0; Vite production bundle generated
|
||||||
|
6. Lockfile integrity:
|
||||||
|
- Before install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
|
||||||
|
- After frozen install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
|
||||||
|
- Equality: ✓ verified
|
||||||
|
- `git diff --quiet origin/next -- pnpm-lock.yaml` rc=0 ✓
|
||||||
|
7. `pnpm format:check` — rc=0; all matched files use Prettier code style
|
||||||
|
8. `git diff --check` — rc=0; no trailing whitespace or merged conflict markers
|
||||||
|
9. New-code origin check: `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty result, as required)
|
||||||
|
10. `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` — rc=0; 5 files / 26 tests passed
|
||||||
|
|
||||||
|
### Scope Audit
|
||||||
|
|
||||||
|
**Authorized in-scope changes:**
|
||||||
|
- ✓ `apps/web/package.json` — dev port pins only
|
||||||
|
- ✓ `apps/web/vite.config.ts` — strictPort flag only
|
||||||
|
- ✓ `apps/web/src/routes.tsx` — route wiring with errorElements
|
||||||
|
- ✓ `apps/web/src/spa/pages/projects.tsx` — read-only SPA list
|
||||||
|
- ✓ `apps/web/src/spa/pages/project-detail.tsx` — read-only SPA detail
|
||||||
|
- ✓ `apps/web/src/spa/pages/tasks.tsx` — read-only SPA list/kanban
|
||||||
|
- ✓ `apps/web/src/spa/pages/resource-route-error-boundaries.tsx` — page-local error components
|
||||||
|
- ✓ `apps/web/src/spa/pages/*.spec.tsx` — fixture-backed tests (4 new)
|
||||||
|
- ✓ `docs/scratchpads/webui-p4-1.md` — append-only record
|
||||||
|
|
||||||
|
**Out-of-scope verification:**
|
||||||
|
- `.mosaic/orchestrator/session.lock` — not modified/staged ✓
|
||||||
|
- `pnpm-lock.yaml` — not modified ✓
|
||||||
|
- `apps/gateway/**` — not modified ✓
|
||||||
|
- `packages/**` — not modified ✓
|
||||||
|
- Chat SPA — not modified ✓
|
||||||
|
- Settings, admin, navigation, or auth flow — not modified ✓
|
||||||
|
|
||||||
|
### Next Action
|
||||||
|
|
||||||
|
Commit, run authorized queue guard, and push only `feat/webui-p4-1` (no PR, no merge).
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# WebUI Phase P — File / Folder Structure & Migration Map
|
||||||
|
|
||||||
|
> **Status:** living document — first pass. Structure and increment status are verified against
|
||||||
|
> `next` as of merge `8c27024d`. Details (per-surface component inventories, exact route tables,
|
||||||
|
> test matrices) are still being fleshed out; extend the stub sections below rather than rewriting
|
||||||
|
> the verified structure.
|
||||||
|
|
||||||
|
## 1. What Phase P is
|
||||||
|
|
||||||
|
Phase P migrates the Mosaic **web UI** (`apps/web`) from the legacy **Next.js App Router** app to a
|
||||||
|
**Vite + React Router single-page app (SPA)** that the **Gateway serves same-origin** on
|
||||||
|
`:14242`. The RFC splits the work into **six increments (P1–P6)**; the P1 PR title records this as
|
||||||
|
"increment 1/6".
|
||||||
|
|
||||||
|
The migration is deliberately **incremental and non-destructive**: the new SPA is built up
|
||||||
|
_beside_ the existing Next app, sharing one `apps/web/src/lib` networking/auth layer, until the
|
||||||
|
final cutover (P5) removes the Next tree. At every point in between, **both app trees exist in the
|
||||||
|
same package** — this is intentional, not drift.
|
||||||
|
|
||||||
|
## 2. Current tree on `next` (dual-app, transitional)
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/
|
||||||
|
├── next.config.ts # legacy Next.js config (removed at P5)
|
||||||
|
├── vite.config.ts # SPA build + DEV proxy config (canonical from P5)
|
||||||
|
├── package.json # dev/build default to NEXT today; :vite variants opt in
|
||||||
|
└── src/
|
||||||
|
├── main.tsx # ── SPA entry (Vite)
|
||||||
|
├── routes.tsx # ── SPA React Router route table
|
||||||
|
├── spa/ # ── NEW SPA surfaces
|
||||||
|
│ ├── guards.tsx # guest / authenticated route guards
|
||||||
|
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3)
|
||||||
|
│ └── chat/ # P3 typed chat: use-chat-connection, commands-panel,
|
||||||
|
│ # session-panel, message-transcript, tool-call-list, composer
|
||||||
|
│
|
||||||
|
├── lib/ # ── SHARED by BOTH trees (origin-relative networking + auth)
|
||||||
|
│ ├── api.ts # fetch wrapper — relative /api/...
|
||||||
|
│ ├── socket.ts # Socket.IO singleton — relative /chat
|
||||||
|
│ ├── auth-client.ts # BetterAuth client — relative /api/auth/...
|
||||||
|
│ ├── auth-redirect.ts # post-auth redirect resolution (protocol-relative rejected)
|
||||||
|
│ ├── chat-contract.ts # P3 typed chat wire contract (runtime-guarded)
|
||||||
|
│ ├── sso.ts · types.ts · cn.ts
|
||||||
|
│
|
||||||
|
├── app/ # ══ LEGACY Next.js App Router (removed at P5)
|
||||||
|
│ ├── (auth)/{login,register}/
|
||||||
|
│ ├── (dashboard)/{admin,chat,projects,projects/[id],settings,tasks}/
|
||||||
|
│ ├── auth/provider/[provider]/
|
||||||
|
│ └── layout.tsx · page.tsx · globals.css
|
||||||
|
│
|
||||||
|
├── components/ # ══ LEGACY Next component library (auth, chat, layout,
|
||||||
|
│ # projects, settings, tasks, ui) — ported into spa/ across P3/P4
|
||||||
|
└── providers/ # ══ theme-provider (legacy; SPA equivalent under providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
Legend: `──` new SPA (keep), `══` legacy Next (removed at P5), shared `lib/` in the middle.
|
||||||
|
|
||||||
|
## 3. Networking / serving model (why it's same-origin)
|
||||||
|
|
||||||
|
- The SPA speaks **origin-relative paths only**: `/api/...`, `/api/auth/...`, `/chat`. No
|
||||||
|
`NEXT_PUBLIC_*` / `VITE_*` origin var, no hard-coded `http://localhost:14242` under
|
||||||
|
`apps/web/src`.
|
||||||
|
- **Dev:** `vite.config.ts` runs a dev-only proxy that forwards those paths to the Gateway (so the
|
||||||
|
SPA on its dev port and the Gateway on `:14242` behave as one origin).
|
||||||
|
- **Prod (target):** the SPA is **same-origin with the Gateway** — the Gateway serves the built
|
||||||
|
static bundle and the API/WS on `:14242`, so no proxy and no CORS. _(The Gateway does not serve
|
||||||
|
the web `dist` yet — adding that is the core of P5; see §5.)_
|
||||||
|
|
||||||
|
## 4. Build scripts (`apps/web/package.json`)
|
||||||
|
|
||||||
|
| Script | Today | Notes |
|
||||||
|
| ----------------------------- | -------------------------------------------- | ------------------------------ |
|
||||||
|
| `dev` | `next dev` | legacy dev server |
|
||||||
|
| `dev:vite` | `vite` | SPA dev server (+ dev proxy) |
|
||||||
|
| `build` | `node ../../scripts/build-web.mjs` | currently a **Next** build |
|
||||||
|
| `build:vite` | `vite build` | SPA production build → `dist/` |
|
||||||
|
| `lint` / `typecheck` / `test` | `eslint src` / `tsc --noEmit` / `vitest run` | tree-agnostic |
|
||||||
|
|
||||||
|
At **P5** the `:vite` variants become the defaults (`dev`→vite, `build`→vite build) and the Next
|
||||||
|
build path is retired.
|
||||||
|
|
||||||
|
## 5. Increment map (P1–P6)
|
||||||
|
|
||||||
|
| # | Increment | Branch | Status |
|
||||||
|
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
|
||||||
|
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
|
||||||
|
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
|
||||||
|
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | 🚧 in progress (unmerged) |
|
||||||
|
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | _tbd_ | ⏳ not started |
|
||||||
|
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
|
||||||
|
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
|
||||||
|
|
||||||
|
Each increment follows the same delivery pipeline: brief traceable to the RFC → author →
|
||||||
|
**independent** integrator verification (build+test+typecheck+lint) → **independent** code + security
|
||||||
|
review (author ≠ reviewer) → author remediates → branch + PR to `next` → **independent** merge-gate
|
||||||
|
merges. Author self-reports are not trusted; every gate is re-derived independently.
|
||||||
|
|
||||||
|
## 6. Known dependency / blocker
|
||||||
|
|
||||||
|
- **Issue #1145 — Gateway `dist` boot is broken** (DI failure on a defaulted constructor param);
|
||||||
|
the Gateway currently runs **dev-mode only**. This is a **hard precondition for P5**: the Gateway
|
||||||
|
cannot serve the SPA `dist` on `:14242` until `dist` boot works. P3/P4 remain on the dev-proxy
|
||||||
|
topology meanwhile.
|
||||||
|
|
||||||
|
## 7. Not part of Phase P (disambiguation)
|
||||||
|
|
||||||
|
`docs/plans/2026-08-09-webui-fleet-claude-bridge.md` and
|
||||||
|
`docs/scratchpads/webui-fleet-bridge-plan.md` describe a **separate** WebUI ↔ fleet/Claude bridge
|
||||||
|
effort. They are **not** the Phase P SPA migration and should not be conflated with the increments
|
||||||
|
above.
|
||||||
|
|
||||||
|
## 8. Where the detail lives (extend these)
|
||||||
|
|
||||||
|
- Per-increment working notes: `docs/scratchpads/webui-p*-*.md` (e.g. `webui-p2-data-auth.md`).
|
||||||
|
- _Stub — to flesh out:_ per-surface component inventory (which `components/*` port to which
|
||||||
|
`spa/*`), the full SPA route table, the P5 cutover checklist, and the P6 CI/image plan.
|
||||||
@@ -193,16 +193,19 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
'Your timezone': 'UTC',
|
'Your timezone': 'UTC',
|
||||||
});
|
});
|
||||||
|
|
||||||
await runWizard({
|
await expect(
|
||||||
|
runWizard({
|
||||||
mosaicHome: tmpDir,
|
mosaicHome: tmpDir,
|
||||||
sourceDir: tmpDir,
|
sourceDir: tmpDir,
|
||||||
prompter,
|
prompter,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
skipGatewayNpmInstall: true,
|
skipGatewayNpmInstall: true,
|
||||||
});
|
}),
|
||||||
|
).rejects.toThrow('Gateway configuration failed');
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
const logs = prompter.getLogs();
|
||||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||||
|
expect(logs.some((line) => line.includes('Gateway configuration failed'))).toBe(true);
|
||||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
|||||||
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
|
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
|
||||||
| QA / test strategy | `guides/QA-TESTING.md` |
|
| QA / test strategy | `guides/QA-TESTING.md` |
|
||||||
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
|
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
|
||||||
|
| Writing style (docs, comms, any prose) | `guides/WRITING-STYLE.md` |
|
||||||
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
||||||
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
||||||
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ Master/slave model:
|
|||||||
- Do not perform destructive git/file actions without explicit instruction.
|
- Do not perform destructive git/file actions without explicit instruction.
|
||||||
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
||||||
|
|
||||||
|
### Output standards (writing + code)
|
||||||
|
|
||||||
|
- Technical documentation follows **MOS-STE** (Mosaic Simplified Technical English — an adapted ASD-STE100 profile): short sentences, one instruction per sentence, active voice, one word per meaning, one term per concept. Full rules: `~/.config/mosaic/guides/WRITING-STYLE.md`.
|
||||||
|
- Apply MOS-STE **hardest to verification artifacts** (acceptance criteria, witness predicates, gate/alarm conditions). There an ambiguous term produces a false green, not just a confused reader.
|
||||||
|
- Source code follows the **Google Style Guide** for the language.
|
||||||
|
- User-facing comms follow the user's declared `communicationStyle` in `USER.md` "Communication Preferences" (`direct` | `friendly` | `formal`, default `direct`); `guides/WRITING-STYLE.md` §5 maps each value to output. The documentation standard does not change with user preference.
|
||||||
|
- **Carve-out:** MOS-STE does NOT apply to content that must carry a specific human voice (letters, personal or marketing prose, voice-matched output). A declared voice profile wins.
|
||||||
|
|
||||||
### Secrets handling (HARD RULE)
|
### Secrets handling (HARD RULE)
|
||||||
|
|
||||||
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Writing Style Standard — MOS-STE (MANDATORY)
|
||||||
|
|
||||||
|
This guide defines how agents write. It sets one style standard per output type.
|
||||||
|
It is written in the standard it defines, as a worked example.
|
||||||
|
|
||||||
|
**Adapted, not compliant.** MOS-STE (Mosaic Simplified Technical English) is an
|
||||||
|
adapted profile of ASD-STE100. Mosaic does not license or certify against
|
||||||
|
ASD-STE100. Mosaic uses the load-bearing rules and fits them to agent work. This
|
||||||
|
is the same stance Mosaic takes toward DO-178B/C: use the rigor, do not claim the
|
||||||
|
certification.
|
||||||
|
|
||||||
|
## Scope — which standard governs which output
|
||||||
|
|
||||||
|
| Output type | Standard |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| Technical documentation (READMEs, runbooks, PRDs, procedures, ADRs, guides, acceptance criteria, design docs) | **MOS-STE** (this guide) |
|
||||||
|
| Source code and code comments | **Google Style Guide** for the language (§4) |
|
||||||
|
| Inter-agent comms | MOS-STE by default (concise, structured) |
|
||||||
|
| User-facing comms | **Per-user style choice** — read `USER.md` "Communication Preferences" (§5) |
|
||||||
|
| End-user prose the user owns (marketing, letters, personal writing, voice-matched content) | The user's declared voice. MOS-STE does NOT apply. |
|
||||||
|
|
||||||
|
**The user-voice carve-out is absolute.** Do not apply MOS-STE to content that
|
||||||
|
must carry a specific human voice (for example a cover letter, a personal
|
||||||
|
message, or marketing copy). That content needs the user's voice. MOS-STE would
|
||||||
|
damage it. When a project declares a voice profile, that profile wins.
|
||||||
|
|
||||||
|
## 1. Why one standard
|
||||||
|
|
||||||
|
Agent documentation drifts across projects. Different agents use different terms,
|
||||||
|
sentence styles, and structures for the same concept. Readers lose time.
|
||||||
|
Assumptions hide in ambiguous prose. One standard gives agents a clear target. It
|
||||||
|
gives reviewers a clear test.
|
||||||
|
|
||||||
|
## 2. Where MOS-STE matters most — verification artifacts
|
||||||
|
|
||||||
|
Apply MOS-STE hardest to acceptance criteria, witness predicates, gate
|
||||||
|
definitions, and alarm conditions. In prose, an ambiguous term produces a
|
||||||
|
confused reader. In a verification artifact, an ambiguous term produces a false
|
||||||
|
green — a check that passes without testing the claim.
|
||||||
|
|
||||||
|
The one-term-one-concept rule (rule 9) is the guard. When one word names two
|
||||||
|
concepts in one predicate, the check can test the wrong concept and still pass.
|
||||||
|
|
||||||
|
**Worked failure.** A rename used a witness predicate with three clauses: ref A
|
||||||
|
present, ref B absent, tip committed from this host. Every clause tested the git
|
||||||
|
_ref_ (the channel). The claim under test was about a _field inside the payload_.
|
||||||
|
The word "beacon" named two concepts in one sentence. Deleting ref B was the next
|
||||||
|
scheduled step. That step flips the last clause green and certifies a state in
|
||||||
|
which the payload still names the wrong host. The predicate was one planned action
|
||||||
|
away from a false green on its normal path. The payload field was never tested.
|
||||||
|
|
||||||
|
Rule: when N failure modes share one observable, the observable is not a
|
||||||
|
diagnostic. In a verification artifact, that ambiguity does not confuse a reader —
|
||||||
|
it certifies the defect.
|
||||||
|
|
||||||
|
## 3. MOS-STE rules
|
||||||
|
|
||||||
|
### 3.1 Sentence rules
|
||||||
|
|
||||||
|
1. Keep sentences short. Use 20 words or fewer for a procedure. Use 25 words or
|
||||||
|
fewer for a description. (Reasoning and doctrine prose relaxes this limit —
|
||||||
|
see §3.4. A future lint enforces §3.1, not §3.4.)
|
||||||
|
2. Write one instruction per sentence. In a procedure, give one command per step.
|
||||||
|
3. Use the active voice. Write "Run the script." Do not write "The script should
|
||||||
|
be run."
|
||||||
|
4. Use the imperative for instructions. Start the sentence with the verb.
|
||||||
|
5. Use simple verb tenses. Prefer the present tense. Avoid the perfect and
|
||||||
|
progressive tenses when a simple tense works.
|
||||||
|
6. Do not use an `-ing` form when it makes the meaning unclear.
|
||||||
|
7. Write positive statements. State what to do, not only what to avoid.
|
||||||
|
|
||||||
|
### 3.2 Word rules
|
||||||
|
|
||||||
|
8. Use one word for one meaning. Do not use the same word in two senses.
|
||||||
|
9. Use one term for one concept. Do not use synonyms for variety. Example: choose
|
||||||
|
`secret`, `credential`, or `key` for each concept, and keep it.
|
||||||
|
10. Use articles (`a`, `the`). Do not drop words to save space.
|
||||||
|
11. Keep an approved-terms glossary per project. Add each domain noun and each
|
||||||
|
chosen verb. Technical names (for example `Vault`, `cgroup`, `systemd`) are
|
||||||
|
always allowed.
|
||||||
|
12. Define an abbreviation at its first use. Then use it consistently.
|
||||||
|
|
||||||
|
### 3.3 Structure rules
|
||||||
|
|
||||||
|
13. Use a list for parallel items or sequential steps. Do not put them in one long
|
||||||
|
sentence.
|
||||||
|
14. Use a table for data with more than two dimensions.
|
||||||
|
15. Use parallel structure in headings and steps.
|
||||||
|
16. Repeat the noun. Do not use a pronoun when the reference is unclear.
|
||||||
|
|
||||||
|
### 3.4 Adaptation notes (where MOS-STE deviates from ASD-STE100, and why)
|
||||||
|
|
||||||
|
- **No licensed dictionary.** ASD-STE100 ships a controlled dictionary under
|
||||||
|
copyright. MOS-STE uses per-project glossaries instead (rule 11).
|
||||||
|
- **Domain terms are allowed.** MOS-STE keeps every term the work needs.
|
||||||
|
- **Reasoning prose gets structure, not amputation.** Apply the sentence and word
|
||||||
|
rules to design and doctrine writing. Allow the length a subtle argument needs.
|
||||||
|
Readable-first beats rule-strict when the two conflict.
|
||||||
|
|
||||||
|
## 4. Code — Google Style Guide
|
||||||
|
|
||||||
|
Write source code to the Google Style Guide for the language (Python, TypeScript,
|
||||||
|
Shell, Go, and so on). Match the existing file when a local convention already
|
||||||
|
exists. Keep code comments to the MOS-STE sentence and word rules.
|
||||||
|
|
||||||
|
## 5. User-facing comms — a per-user choice
|
||||||
|
|
||||||
|
Mosaic is multi-user. Different users want different comms styles. The framework
|
||||||
|
already carries the selectable setting: `communicationStyle` (`direct` |
|
||||||
|
`friendly` | `formal`, default `direct`). `mosaic init` writes it, and the
|
||||||
|
builder renders it into the generated `USER.md` "Communication Preferences"
|
||||||
|
section. This guide adds the OUTPUT meaning of each value; do not invent new
|
||||||
|
values.
|
||||||
|
|
||||||
|
The builder renders the style as prose bullets, not the token name, so match on
|
||||||
|
the leading bullet the generated `USER.md` actually contains:
|
||||||
|
|
||||||
|
| `USER.md` leading bullet | Style | User-facing output |
|
||||||
|
| ----------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| "Direct and concise" | `direct` (default) | MOS-STE structure — short, active, defined terms, tables for overview. |
|
||||||
|
| "Warm and conversational" | `friendly` | Warmer register. Full sentences, explain reasoning, fewer tables. |
|
||||||
|
| "Professional and structured" | `formal` | Professional and structured. Thorough, with explicit recommendations. |
|
||||||
|
|
||||||
|
This setting governs **user-facing comms only**. It does not change the
|
||||||
|
documentation standard (§3), which is always MOS-STE regardless of the value.
|
||||||
|
|
||||||
|
## 6. Enforcement
|
||||||
|
|
||||||
|
- **Now:** human review only. **No mechanical prose check exists today.** The
|
||||||
|
pre-push gate runs typecheck, lint, build, and tests; it inspects no prose.
|
||||||
|
Reviewers check output against the scope table and the MOS-STE rules by hand.
|
||||||
|
- **Future:** an MOS-STE lint check (built from the §3.1 sentence rules) and a
|
||||||
|
Google-style linter in the pre-push gate. A future linter enforces §3.1, not
|
||||||
|
§3.4 — see the note at rule 1.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Mosaic lease promotion was processed mechanically; no action is needed.
|
||||||
@@ -32,6 +32,18 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "^/mosaic-promote$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
@@ -81,8 +93,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||||
"timeout": 3
|
"timeout": 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ prompt_if_empty() {
|
|||||||
|
|
||||||
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||||
if [[ -n "$default_value" ]]; then
|
if [[ -n "$default_value" ]]; then
|
||||||
eval "$var_name=\"$default_value\""
|
printf -v "$var_name" %s "$default_value"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
echo "[mosaic-init] ERROR: --$var_name is required in non-interactive mode" >&2
|
echo "[mosaic-init] ERROR: --$var_name is required in non-interactive mode" >&2
|
||||||
@@ -115,7 +115,7 @@ prompt_if_empty() {
|
|||||||
if [[ -z "$value" && -n "$default_value" ]]; then
|
if [[ -z "$value" && -n "$default_value" ]]; then
|
||||||
value="$default_value"
|
value="$default_value"
|
||||||
fi
|
fi
|
||||||
eval "$var_name=\"$value\""
|
printf -v "$var_name" %s "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt_multiline() {
|
prompt_multiline() {
|
||||||
@@ -129,7 +129,7 @@ prompt_multiline() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||||
eval "$var_name=\"$default_value\""
|
printf -v "$var_name" %s "$default_value"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ prompt_multiline() {
|
|||||||
if [[ -z "$value" ]]; then
|
if [[ -z "$value" ]]; then
|
||||||
value="$default_value"
|
value="$default_value"
|
||||||
fi
|
fi
|
||||||
eval "$var_name=\"$value\""
|
printf -v "$var_name" %s "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Existing file detection ────────────────────────────────────
|
# ── Existing file detection ────────────────────────────────────
|
||||||
|
|||||||
@@ -233,6 +233,14 @@ for runtime_file in \
|
|||||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
|
||||||
|
mkdir -p "$HOME/.claude/commands"
|
||||||
|
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
|
||||||
|
[[ -f "$command_file" ]] || continue
|
||||||
|
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
||||||
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
||||||
if [[ -f "$opencode_adapter" ]]; then
|
if [[ -f "$opencode_adapter" ]]; then
|
||||||
|
|||||||
@@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
||||||
|
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
||||||
|
# commands/launch.js):
|
||||||
|
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
||||||
|
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
||||||
|
# codex CODEX_HOME -> <home>/skills
|
||||||
|
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
||||||
link_targets=(
|
link_targets=(
|
||||||
|
"$MOSAIC_HOME/.claude/skills"
|
||||||
|
"$MOSAIC_HOME/.codex/skills"
|
||||||
|
"$MOSAIC_HOME/.opencode/opencode/skills"
|
||||||
|
"$MOSAIC_HOME/.pi/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
||||||
|
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
||||||
|
# they persist and make a "clean" base install look mosaic-managed.
|
||||||
|
legacy_link_targets=(
|
||||||
"$HOME/.claude/skills"
|
"$HOME/.claude/skills"
|
||||||
"$HOME/.codex/skills"
|
"$HOME/.codex/skills"
|
||||||
"$HOME/.config/opencode/skills"
|
"$HOME/.config/opencode/skills"
|
||||||
@@ -245,13 +262,72 @@ prune_stale_links_in_target() {
|
|||||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||||
# is unproven and the link must be preserved.
|
# is unproven and the link must be preserved.
|
||||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
||||||
|
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
|
||||||
|
# matches every absolute path. Combined with the is_mosaic_skill_name skip
|
||||||
|
# above, that inverts the function precisely — it would delete exactly the
|
||||||
|
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
|
||||||
|
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
rm -f "$link_path"
|
rm -f "$link_path"
|
||||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||||
fi
|
fi
|
||||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
||||||
|
#
|
||||||
|
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
||||||
|
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
||||||
|
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
||||||
|
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
||||||
|
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
||||||
|
#
|
||||||
|
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
||||||
|
# missing, and an empty dir is the correct end state, not an absent one.
|
||||||
|
cleanup_legacy_target() {
|
||||||
|
local target_dir="$1"
|
||||||
|
local removed=0 kept=0
|
||||||
|
|
||||||
|
[[ -d "$target_dir" ]] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' link_path; do
|
||||||
|
local resolved owned=0
|
||||||
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
||||||
|
# match every absolute path and delete foreign links.
|
||||||
|
if [[ -n "$resolved" ]]; then
|
||||||
|
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
|
owned=1
|
||||||
|
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
||||||
|
owned=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $owned -eq 1 ]]; then
|
||||||
|
rm -f "$link_path"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
kept=$((kept + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||||
|
|
||||||
|
if [[ $removed -gt 0 ]]; then
|
||||||
|
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for legacy in "${legacy_link_targets[@]}"; do
|
||||||
|
# Skip anything that is also a current target, so isolation can never
|
||||||
|
# self-destruct if the two lists ever overlap.
|
||||||
|
skip=0
|
||||||
|
for target in "${link_targets[@]}"; do
|
||||||
|
[[ "$legacy" == "$target" ]] && skip=1
|
||||||
|
done
|
||||||
|
[[ $skip -eq 1 ]] && continue
|
||||||
|
cleanup_legacy_target "$legacy"
|
||||||
|
done
|
||||||
|
|
||||||
for target in "${link_targets[@]}"; do
|
for target in "${link_targets[@]}"; do
|
||||||
mkdir -p "$target"
|
mkdir -p "$target"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Source only the prompt helpers; executing mosaic-init itself requires templates.
|
||||||
|
source <(head -n 144 "$(dirname "$0")/mosaic-init")
|
||||||
|
|
||||||
|
rm -f /tmp/pwned
|
||||||
|
payload='literal "$(touch /tmp/pwned)"'
|
||||||
|
AGENT_NAME=""
|
||||||
|
prompt_if_empty AGENT_NAME "Agent name" <<<"$payload"
|
||||||
|
|
||||||
|
[[ "$AGENT_NAME" == "$payload" ]] || {
|
||||||
|
echo "FAIL: prompt answer did not round-trip literally" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ ! -e /tmp/pwned ]] || {
|
||||||
|
echo "FAIL: prompt answer executed code" >&2
|
||||||
|
rm -f /tmp/pwned
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "mosaic-init RCE regression: PASS"
|
||||||
@@ -465,12 +465,24 @@ while true; do
|
|||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
else
|
exit 3
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
|
||||||
fi
|
fi
|
||||||
|
if [[ "$PURPOSE" == "push" ]]; then
|
||||||
|
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 3
|
exit 3
|
||||||
;;
|
;;
|
||||||
terminal-failure|malformed|unknown)
|
terminal-failure)
|
||||||
|
if [[ "$PURPOSE" == "push" ]]; then
|
||||||
|
echo "[ci-queue-wait] queue-clear state=terminal-failure purpose=push branch=${BRANCH}; no queued or running CI."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=terminal-failure purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
|
exit 3
|
||||||
|
;;
|
||||||
|
malformed|unknown)
|
||||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 3
|
exit 3
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
|
|||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
|
CO_AUTHOR_TRAILERS=false
|
||||||
|
ESCALATE_TO=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -27,12 +29,16 @@ Options:
|
|||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after 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
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
-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 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--expect-head)
|
--expect-head)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
EXPECT_HEAD="$2"
|
EXPECT_HEAD="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--co-author-trailers)
|
||||||
|
CO_AUTHOR_TRAILERS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--escalate-to)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --escalate-to requires one principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ESCALATE_TO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -88,17 +110,30 @@ 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
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||||
|
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --escalate-to is valid only with --co-author-trailers." >&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_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_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())')"
|
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
|
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||||
|
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||||
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; 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
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
write_curl_auth_config() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local mode="$1" credential="$2"
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
printf '%s' "$credential" | python3 -c '
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
import sys
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
mode = sys.argv[1]
|
||||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
credential = sys.stdin.read()
|
||||||
|
if not credential or any(char in credential for char in "\r\n"):
|
||||||
|
raise SystemExit(1)
|
||||||
|
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
if mode == "token":
|
||||||
|
print(f"header = \"Authorization: token {escaped}\"")
|
||||||
|
elif mode == "basic":
|
||||||
|
print(f"user = \"{escaped}\"")
|
||||||
|
else:
|
||||||
|
raise SystemExit(1)
|
||||||
|
' "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
|
LAST_GITEA_ERROR=""
|
||||||
|
MERGE_TEMP_DIRS=()
|
||||||
|
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||||
|
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||||
|
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||||
|
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||||
|
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
GITEA_CURL_BOUNDS=(
|
||||||
|
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||||
|
--max-time "$GITEA_CURL_MAX_TIME"
|
||||||
|
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||||
|
)
|
||||||
|
|
||||||
|
format_gitea_error_response() {
|
||||||
|
local response_file="$1"
|
||||||
|
python3 - "$response_file" <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
head_sha, delete_branch = sys.argv[1:]
|
with open(sys.argv[1], "rb") as handle:
|
||||||
|
raw = handle.read(65536)
|
||||||
|
try:
|
||||||
|
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
message = "non-JSON response omitted"
|
||||||
|
else:
|
||||||
|
if isinstance(response, dict):
|
||||||
|
message = response.get("message") or response.get("error")
|
||||||
|
if not message and response.get("errors") is not None:
|
||||||
|
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
message = None
|
||||||
|
if not message:
|
||||||
|
message = "JSON response contained no error message"
|
||||||
|
message = str(message)
|
||||||
|
if len(message) > 500:
|
||||||
|
message = message[:500] + "..."
|
||||||
|
print(ascii(message))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_merge_temp_dirs() {
|
||||||
|
local path
|
||||||
|
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||||
|
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup_merge_temp_dirs EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
fetch_gitea_pr_head() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
|
local response_file raw_code api_url auth_config curl_rc
|
||||||
|
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
pull = json.load(handle)
|
||||||
|
head = pull.get("head") if isinstance(pull, dict) else None
|
||||||
|
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(sha)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$response_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_gitea_pr_commits() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
|
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
||||||
|
printf '[]' > "$combined_file"
|
||||||
|
|
||||||
|
page=1
|
||||||
|
while true; do
|
||||||
|
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
page = json.load(handle)
|
||||||
|
if not isinstance(page, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(len(page))
|
||||||
|
PY
|
||||||
|
); then
|
||||||
|
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
|
||||||
|
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
combined = json.load(handle)
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
page = json.load(handle)
|
||||||
|
json.dump(combined + page, sys.stdout, separators=(",", ":"))
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file" "$merged_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$merged_file" "$combined_file"
|
||||||
|
rm -f "$page_file"
|
||||||
|
|
||||||
|
if [[ "$page_count" -lt 50 ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
page=$((page + 1))
|
||||||
|
if [[ "$page" -gt 1000 ]]; then
|
||||||
|
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
|
||||||
|
rm -f "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat "$combined_file"
|
||||||
|
rm -f "$combined_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
|
||||||
|
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
|
||||||
|
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
|
||||||
|
build_coauthor_message_fields() {
|
||||||
|
local commits_file="$1" context_file="$2" head_file="$3"
|
||||||
|
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
commits_path, context_path, head_path = sys.argv[1:]
|
||||||
|
with open(commits_path, encoding="utf-8") as handle:
|
||||||
|
commits = json.load(handle)
|
||||||
|
head_sha = open(head_path, encoding="utf-8").read().strip()
|
||||||
|
context_parts = open(context_path, "rb").read().split(b"\0")
|
||||||
|
if len(context_parts) != 4 or context_parts[-1] != b"":
|
||||||
|
raise SystemExit(1)
|
||||||
|
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
|
||||||
|
|
||||||
|
if not isinstance(commits, list) or not commits:
|
||||||
|
print(
|
||||||
|
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
|
||||||
|
f"Refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not poster:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR poster login is empty; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
trailers = []
|
||||||
|
head_seen = False
|
||||||
|
for item in commits:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
|
||||||
|
raise SystemExit(75)
|
||||||
|
sha = str(item.get("sha") or "<unknown>")
|
||||||
|
if sha == head_sha:
|
||||||
|
head_seen = True
|
||||||
|
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
|
||||||
|
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||||
|
email = str(commit_author.get("email") or "").strip()
|
||||||
|
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
|
||||||
|
login = str(provider_author.get("login") or "").strip()
|
||||||
|
|
||||||
|
if not login:
|
||||||
|
diagnostic_email = email or "<missing>"
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has author.login=NULL while "
|
||||||
|
f"commit.author.email={diagnostic_email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if (
|
||||||
|
not email.isascii()
|
||||||
|
or not email.isprintable()
|
||||||
|
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||||
|
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has unusable linked identity "
|
||||||
|
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if login == poster or login in seen:
|
||||||
|
continue
|
||||||
|
seen.add(login)
|
||||||
|
trailers.append(f"Co-authored-by: {login} <{email}>")
|
||||||
|
|
||||||
|
if not head_seen:
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not trailers:
|
||||||
|
print("{}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
if not title:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"MergeTitleField": title,
|
||||||
|
"MergeMessageField": "\n".join(trailers),
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_gitea_api_attempt() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3"
|
||||||
|
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
|
||||||
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
|
LAST_GITEA_ERROR=""
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
||||||
|
chmod 0700 "$attempt_dir"
|
||||||
|
MERGE_TEMP_DIRS+=("$attempt_dir")
|
||||||
|
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
|
||||||
|
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
|
||||||
|
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
|
||||||
|
printf '{}' > "$fields_file"
|
||||||
|
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
|
||||||
|
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
|
||||||
|
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
|
||||||
|
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
|
||||||
|
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
|
||||||
|
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
rm -f "$commits_file" "$context_file" "$head_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
fields = json.load(handle)
|
||||||
|
head_sha, delete_branch = sys.argv[2:]
|
||||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
if delete_branch == "true":
|
if delete_branch == "true":
|
||||||
payload["delete_branch_after_merge"] = True
|
payload["delete_branch_after_merge"] = True
|
||||||
|
payload.update(fields)
|
||||||
|
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
|
||||||
|
if payload.get("Do") != "squash" or set(payload) - allowed:
|
||||||
|
raise SystemExit(1)
|
||||||
print(json.dumps(payload, separators=(",", ":")))
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
PY
|
PY
|
||||||
)
|
then
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$fields_file"
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
if [[ -n "$token" ]]; then
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
rm -f "$body_file" "$payload_file"
|
||||||
-X POST \
|
return 1
|
||||||
-H "User-Agent: curl/8" \
|
fi
|
||||||
-H "Authorization: token $token" \
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST -H "User-Agent: curl/8" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$payload" \
|
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
|
||||||
"$api_url" || true)
|
curl_rc=$?
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
rm -f "$body_file"
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
return 0
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
|
||||||
fi
|
fi
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
[[ "$raw_code" =~ ^2 ]]
|
||||||
|
}
|
||||||
|
|
||||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
merge_gitea_with_api() {
|
||||||
if [[ -n "$basic_auth" ]]; then
|
local host="$1" token attempt_rc
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
||||||
-X POST \
|
|
||||||
-u "$basic_auth" \
|
|
||||||
-H "User-Agent: curl/8" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$api_url" || true)
|
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
import json
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
import sys
|
return 1
|
||||||
code, path = sys.argv[1], sys.argv[2]
|
fi
|
||||||
try:
|
if [[ -z "$token" ]]; then
|
||||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
raw = handle.read(500)
|
return 1
|
||||||
data = json.loads(raw) if raw else {}
|
fi
|
||||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||||
except Exception:
|
return 0
|
||||||
try:
|
else
|
||||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
attempt_rc=$?
|
||||||
except Exception:
|
fi
|
||||||
message = "unreadable response"
|
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
return 75
|
||||||
PY
|
fi
|
||||||
rm -f "$body_file"
|
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||||
|
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
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)"
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
@@ -209,6 +615,10 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
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[@]}"
|
||||||
@@ -219,7 +629,7 @@ case "$PLATFORM" in
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
# 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.
|
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||||
merge_gitea_with_api "$HOST"
|
merge_gitea_with_api "$HOST"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -43,22 +43,22 @@ SH
|
|||||||
chmod +x "$STUB_DIR/gh"
|
chmod +x "$STUB_DIR/gh"
|
||||||
|
|
||||||
run_guard() {
|
run_guard() {
|
||||||
local mode="$1"
|
local mode="$1" purpose="${2:-push}"
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR" || exit
|
cd "$REPO_DIR" || exit
|
||||||
export PATH="$STUB_DIR:$PATH"
|
export PATH="$STUB_DIR:$PATH"
|
||||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "$purpose" -t 0 -i 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
assert_case() {
|
assert_case() {
|
||||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
local mode="$1" expected_rc="$2" expected_state="$3" purpose="${4:-push}" output rc
|
||||||
set +e
|
set +e
|
||||||
output=$(run_guard "$mode" 2>&1)
|
output=$(run_guard "$mode" "$purpose" 2>&1)
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||||
@@ -79,10 +79,12 @@ set -e
|
|||||||
: > "$WORK_DIR/gh-calls.log"
|
: > "$WORK_DIR/gh-calls.log"
|
||||||
assert_case success zero terminal-success
|
assert_case success zero terminal-success
|
||||||
assert_case pending nonzero pending
|
assert_case pending nonzero pending
|
||||||
assert_case failure nonzero terminal-failure
|
assert_case failure zero terminal-failure
|
||||||
assert_case late-failure nonzero terminal-failure
|
assert_case late-failure zero terminal-failure
|
||||||
|
assert_case failure nonzero terminal-failure merge
|
||||||
|
assert_case late-failure nonzero terminal-failure merge
|
||||||
|
|
||||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 6 ]]; then
|
||||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
@@ -92,4 +94,4 @@ if [[ "$failures" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
echo "GitHub check-runs regression passed (6/6 purpose-aware cases, including later-page failure)"
|
||||||
|
|||||||
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
|
|||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
|
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||||
|
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||||
|
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||||
|
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||||
|
WATCHDOG_TIMEOUT_SEC=5
|
||||||
|
WATCHDOG_EXIT=90
|
||||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||||
|
|
||||||
|
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||||
|
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
timeout_seconds = float(sys.argv[1])
|
||||||
|
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||||
|
try:
|
||||||
|
return_code = process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
process.wait()
|
||||||
|
print(
|
||||||
|
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||||
|
"before completing its intended path",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(90)
|
||||||
|
|
||||||
|
if return_code < 0:
|
||||||
|
raise SystemExit(128 - return_code)
|
||||||
|
raise SystemExit(return_code)
|
||||||
|
PY
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||||
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
|||||||
|
|
||||||
case "$url" in
|
case "$url" in
|
||||||
*/branches/*)
|
*/branches/*)
|
||||||
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||||
|
while :; do :; done
|
||||||
|
fi
|
||||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||||
exit 7
|
exit 7
|
||||||
fi
|
fi
|
||||||
@@ -44,6 +88,7 @@ case "$url" in
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*/status)
|
*/status)
|
||||||
|
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||||
@@ -53,6 +98,7 @@ case "$url" in
|
|||||||
malformed) printf '%s' 'not-json' ;;
|
malformed) printf '%s' 'not-json' ;;
|
||||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||||
|
unknown) printf '%s' '{"state":"success","statuses":[{"status":"cancelled"}]}' ;;
|
||||||
large-success)
|
large-success)
|
||||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||||
;;
|
;;
|
||||||
@@ -63,7 +109,31 @@ case "$url" in
|
|||||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
SH
|
SH
|
||||||
chmod +x "$STUB_DIR/curl"
|
|
||||||
|
cat > "$STUB_DIR/date" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||||
|
echo "unexpected date invocation: $*" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||||
|
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1002\n'
|
||||||
|
else
|
||||||
|
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1000\n'
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/sleep" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||||
|
|
||||||
run_guard() {
|
run_guard() {
|
||||||
local status_mode="$1"
|
local status_mode="$1"
|
||||||
@@ -83,13 +153,46 @@ run_guard() {
|
|||||||
export GITEA_URL=https://git.example.test
|
export GITEA_URL=https://git.example.test
|
||||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||||
fi
|
fi
|
||||||
|
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||||
|
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||||
|
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
# Provider observation is the synchronization event. The one-second
|
||||||
|
# timeout is subject semantics under virtual time, never a wall wait.
|
||||||
|
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||||
|
# the subject's isolated process group. Neither operation can resolve
|
||||||
|
# to the virtual date/sleep stubs at the front of PATH.
|
||||||
|
local subject_rc
|
||||||
|
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||||
|
subject_rc=0
|
||||||
|
else
|
||||||
|
subject_rc=$?
|
||||||
|
fi
|
||||||
|
return "$subject_rc"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
|
assert_provider_observed() {
|
||||||
|
local name="$1" require_expiration="${2:-0}"
|
||||||
|
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL $name: status provider was not observed" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$require_expiration" -eq 1 ]]; then
|
||||||
|
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
run_assertion() {
|
run_assertion() {
|
||||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||||
local output rc
|
local output rc
|
||||||
@@ -124,22 +227,59 @@ run_assertion() {
|
|||||||
printf '%s\n' "$output" >&2
|
printf '%s\n' "$output" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||||
|
if [[ "$status_mode" == "pending" ]]; then
|
||||||
|
assert_provider_observed "$name" 1
|
||||||
|
else
|
||||||
|
assert_provider_observed "$name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
: > "$WORK_DIR/urls.log"
|
: > "$WORK_DIR/urls.log"
|
||||||
run_assertion success zero success 'state=terminal-success'
|
# Push readiness is queue clearance, not proof that prior CI succeeded.
|
||||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
run_assertion push-success zero success 'state=terminal-success'
|
||||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
run_assertion push-pending nonzero pending 'ASSERTED_NOT_READY'
|
||||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
run_assertion push-failure zero failure 'queue-clear state=terminal-failure purpose=push'
|
||||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
run_assertion push-no-status zero no-status 'queue-clear state=no-status purpose=push'
|
||||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
run_assertion push-aggregate-success-no-status zero aggregate-success-no-status 'queue-clear state=no-status purpose=push'
|
||||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
run_assertion push-require-status nonzero no-status 'ASSERTED_NOT_READY state=no-status' --require-status
|
||||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
run_assertion push-malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
run_assertion push-malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion push-malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion push-unknown nonzero unknown 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion push-large-payload not126 large-success 'state=terminal-success'
|
||||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||||
|
|
||||||
|
# Merge readiness remains fail-closed and requires exact-head terminal success.
|
||||||
|
MOSAIC_TEST_PURPOSE=merge run_assertion merge-success zero success 'state=terminal-success'
|
||||||
|
MOSAIC_TEST_PURPOSE=merge run_assertion merge-failure nonzero failure 'ASSERTED_NOT_READY state=terminal-failure'
|
||||||
|
MOSAIC_TEST_PURPOSE=merge run_assertion merge-no-status nonzero no-status 'ASSERTED_NOT_READY state=no-status'
|
||||||
|
MOSAIC_TEST_PURPOSE=merge run_assertion merge-unknown nonzero unknown 'ASSERTED_NOT_READY state=unknown'
|
||||||
|
|
||||||
|
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||||
|
# can reach the status provider. Only the independent real-clock watchdog may
|
||||||
|
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||||
|
set +e
|
||||||
|
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||||
|
watchdog_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||||
|
printf '%s\n' "$watchdog_output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
@@ -160,6 +300,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
|||||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
assert_provider_observed merge-provider-unreachable
|
||||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||||
@@ -223,6 +364,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
|
|||||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
assert_provider_observed audit-unavailable
|
||||||
|
|
||||||
if [[ "$failures" -ne 0 ]]; then
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||||
|
|||||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "-d" ]]; then
|
if [[ "$prev" == "data" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
|
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-o" ]]; then
|
if [[ "$prev" == "config" ]]; then
|
||||||
prev="-o"
|
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||||
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-d" ]]; then
|
case "$arg" in
|
||||||
prev="-d"
|
-o) prev="-o" ;;
|
||||||
continue
|
-d|--data|--data-binary) prev="data" ;;
|
||||||
fi
|
-K|--config) prev="config" ;;
|
||||||
if [[ "$arg" == "-w" ]]; then
|
-w) write_code=true ;;
|
||||||
write_code=true
|
esac
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ make_fixture() {
|
|||||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||||
git -C "$root/repo" init -q
|
git -C "$root/repo" init -q
|
||||||
git -C "$root/repo" remote add origin "$remote"
|
git -C "$root/repo" remote add origin "$remote"
|
||||||
|
local base_branch="${3:-main}"
|
||||||
cat > "$tools/pr-metadata.sh" <<SH
|
cat > "$tools/pr-metadata.sh" <<SH
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||||
SH
|
SH
|
||||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -29,20 +30,37 @@ SH
|
|||||||
}
|
}
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
make_fixture gitea https://git.example.test/acme/widgets.git next
|
||||||
make_fixture github https://github.com/acme/widgets.git
|
make_fixture github https://github.com/acme/widgets.git main
|
||||||
|
|
||||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
payload=""
|
payload=""
|
||||||
for ((i=1; i<=$#; i++)); do
|
out_file=""
|
||||||
if [[ "${!i}" == "-d" ]]; then
|
while [[ $# -gt 0 ]]; do
|
||||||
j=$((i + 1))
|
case "$1" in
|
||||||
payload="${!j}"
|
-d|--data|--data-binary)
|
||||||
fi
|
payload="$2"
|
||||||
|
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
[[ "$2" == "-" ]] && cat >/dev/null
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w|-X|-H)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
|
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
||||||
printf '200'
|
printf '200'
|
||||||
SH
|
SH
|
||||||
chmod +x "$WORK_DIR/gitea/curl"
|
chmod +x "$WORK_DIR/gitea/curl"
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||||
|
ORIG_PATH="$PATH"
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL $1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
make_case() {
|
||||||
|
local name="$1" case_dir
|
||||||
|
case_dir="$WORK_DIR/$name"
|
||||||
|
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||||
|
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||||
|
chmod +x "$case_dir/pr-merge.sh"
|
||||||
|
|
||||||
|
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||||
|
get_repo_owner() { printf 'acme\n'; }
|
||||||
|
get_repo_name() { printf 'widgets\n'; }
|
||||||
|
get_remote_host() { printf 'git.example.test\n'; }
|
||||||
|
get_gitea_token() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'fixture-token\n'
|
||||||
|
}
|
||||||
|
get_gitea_basic_auth() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||||
|
printf 'fixture-user:fixture-password\n'
|
||||||
|
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
get_gitea_login_for_host() { return 1; }
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||||
|
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||||
|
else
|
||||||
|
title='Preserve both branch authors'
|
||||||
|
fi
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/python3" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
url=""
|
||||||
|
method="GET"
|
||||||
|
out_file=""
|
||||||
|
data=""
|
||||||
|
config=""
|
||||||
|
auth_mode="none"
|
||||||
|
has_max_filesize=0
|
||||||
|
has_max_time=0
|
||||||
|
has_connect_timeout=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-X)
|
||||||
|
method="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--data|--data-binary)
|
||||||
|
data="$2"
|
||||||
|
if [[ "$data" == @* ]]; then
|
||||||
|
data=$(<"${data#@}")
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
if [[ "$2" == "-" ]]; then
|
||||||
|
config=$(cat)
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-filesize)
|
||||||
|
has_max_filesize=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-time)
|
||||||
|
has_max_time=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
has_connect_timeout=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-H|--header|-u|--user)
|
||||||
|
if [[ "$2" == *"fixture-token"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
if [[ "$2" == *"fixture-password"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
http://*|https://*)
|
||||||
|
url="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||||
|
auth_mode="token"
|
||||||
|
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||||
|
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||||
|
auth_mode="basic"
|
||||||
|
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||||
|
fi
|
||||||
|
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||||
|
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/pulls/42)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||||
|
head_sha=4444444444444444444444444444444444444444
|
||||||
|
fi
|
||||||
|
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/commits*)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified)
|
||||||
|
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
else
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
null-login)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||||
|
;;
|
||||||
|
unsafe-identity)
|
||||||
|
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
single)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown commits mode" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/merge)
|
||||||
|
body='{}'
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||||
|
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||||
|
code=409
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||||
|
body='{"message":"permission denied"}'
|
||||||
|
code=403
|
||||||
|
else
|
||||||
|
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/users/*)
|
||||||
|
body='{"message":"not found"}'
|
||||||
|
code=404
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
body='{"message":"unexpected URL"}'
|
||||||
|
code=500
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$out_file" ]]; then
|
||||||
|
printf '%s' "$body" > "$out_file"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
printf '%s' "$code"
|
||||||
|
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||||
|
oversize) exit 63 ;;
|
||||||
|
stalled) exit 28 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||||
|
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||||
|
printf '%s\n' "$case_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local case_dir="$1" mode="$2"
|
||||||
|
shift 2
|
||||||
|
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||||
|
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||||
|
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||||
|
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||||
|
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||||
|
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||||
|
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||||
|
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||||
|
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||||
|
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||||
|
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||||
|
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||||
|
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||||
|
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||||
|
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||||
|
# linked author.login and that same commit's author email. No /users lookup.
|
||||||
|
verified_dir=$(make_case verified)
|
||||||
|
set +e
|
||||||
|
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
verified_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$verified_rc" -ne 0 ]]; then
|
||||||
|
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||||
|
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||||
|
fail "verified multi-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||||
|
"MergeTitleField": "Preserve both branch authors",
|
||||||
|
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||||
|
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||||
|
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||||
|
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||||
|
fail "verified path performed a forbidden second /users lookup"
|
||||||
|
fi
|
||||||
|
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||||
|
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A linked email containing a terminal escape must block before mutation.
|
||||||
|
escape_email_dir=$(make_case escape-email)
|
||||||
|
set +e
|
||||||
|
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
escape_email_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||||
|
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||||
|
|
||||||
|
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||||
|
for failure_mode in oversize stalled; do
|
||||||
|
failure_dir=$(make_case "curl-$failure_mode")
|
||||||
|
set +e
|
||||||
|
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
failure_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||||
|
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The authenticated head is re-read under the mutation credential but cannot
|
||||||
|
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||||
|
# or mutation even though the provider returned a valid new SHA.
|
||||||
|
moved_dir=$(make_case moved-head)
|
||||||
|
set +e
|
||||||
|
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||||
|
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
moved_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||||
|
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||||
|
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||||
|
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||||
|
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||||
|
|
||||||
|
# Token resolution failure is not an authentication response. It must fail
|
||||||
|
# closed instead of borrowing a Basic credential under a different principal.
|
||||||
|
token_missing_dir=$(make_case token-missing)
|
||||||
|
set +e
|
||||||
|
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||||
|
run_case "$token_missing_dir" single 2>&1)
|
||||||
|
token_missing_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||||
|
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||||
|
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||||
|
|
||||||
|
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||||
|
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||||
|
set +e
|
||||||
|
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
basic_rc_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||||
|
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||||
|
|
||||||
|
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||||
|
# resolving or attempting Basic Auth.
|
||||||
|
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||||
|
set +e
|
||||||
|
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_inspect_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||||
|
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||||
|
|
||||||
|
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||||
|
fallback_merge_dir=$(make_case fallback-merge)
|
||||||
|
set +e
|
||||||
|
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||||
|
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_merge_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||||
|
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||||
|
|
||||||
|
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||||
|
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||||
|
null_dir=$(make_case null-login)
|
||||||
|
set +e
|
||||||
|
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
null_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||||
|
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||||
|
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||||
|
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||||
|
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||||
|
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||||
|
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||||
|
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||||
|
# including an invalid non-head SHA that contains control characters.
|
||||||
|
unsafe_dir=$(make_case unsafe-identity)
|
||||||
|
set +e
|
||||||
|
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
unsafe_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||||
|
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||||
|
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||||
|
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||||
|
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||||
|
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||||
|
title_dir=$(make_case title-injection)
|
||||||
|
set +e
|
||||||
|
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||||
|
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
title_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||||
|
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||||
|
|
||||||
|
# Provider failures remain diagnosable after their temporary response file is
|
||||||
|
# removed, but provider-controlled control characters stay log-safe.
|
||||||
|
error_dir=$(make_case provider-error)
|
||||||
|
set +e
|
||||||
|
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||||
|
run_case "$error_dir" single 2>&1)
|
||||||
|
error_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||||
|
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||||
|
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||||
|
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||||
|
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||||
|
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# Authorization denials likewise fail closed instead of changing principals.
|
||||||
|
forbidden_dir=$(make_case forbidden)
|
||||||
|
set +e
|
||||||
|
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||||
|
run_case "$forbidden_dir" single 2>&1)
|
||||||
|
forbidden_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||||
|
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||||
|
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||||
|
# without a named principal is refused before any provider operation.
|
||||||
|
principal_dir=$(make_case missing-principal)
|
||||||
|
set +e
|
||||||
|
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||||
|
principal_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||||
|
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||||
|
|
||||||
|
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||||
|
# set -u unbound-variable crash.
|
||||||
|
value_dir=$(make_case missing-principal-value)
|
||||||
|
set +e
|
||||||
|
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||||
|
value_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||||
|
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||||
|
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||||
|
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||||
|
|
||||||
|
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||||
|
# compatible and hardcoded to squash, with no optional message fields.
|
||||||
|
single_dir=$(make_case single)
|
||||||
|
set +e
|
||||||
|
single_output=$(run_case "$single_dir" single 2>&1)
|
||||||
|
single_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$single_rc" -ne 0 ]]; then
|
||||||
|
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||||
|
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||||
|
fail "ordinary single-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||||
|
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||||
|
|
||||||
|
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||||
|
method_dir=$(make_case method-refusal)
|
||||||
|
set +e
|
||||||
|
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||||
|
method_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||||
|
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||||
|
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||||
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
|
|||||||
MAX_STATE: Final = 4 * 1024 * 1024
|
MAX_STATE: Final = 4 * 1024 * 1024
|
||||||
MAX_PENDING_TOKENS: Final = 256
|
MAX_PENDING_TOKENS: Final = 256
|
||||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
MAX_LEASE_TTL_SECONDS: Final = 3600
|
||||||
STATE_VERSION: Final = 1
|
STATE_VERSION: Final = 1
|
||||||
READ_DEADLINE_SECONDS: Final = 1.0
|
READ_DEADLINE_SECONDS: Final = 1.0
|
||||||
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
||||||
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
|||||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||||
LEASE_VERIFIED: Final = "VERIFIED"
|
LEASE_VERIFIED: Final = "VERIFIED"
|
||||||
READ_ONLY_TOOLS: Final = {
|
READ_ONLY_TOOLS: Final = {
|
||||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
"claude": frozenset({"Read", "Grep", "Glob"}),
|
||||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
"pi": frozenset({"read", "ls"}),
|
||||||
}
|
}
|
||||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _self_starttime() -> str | None:
|
||||||
|
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
||||||
|
|
||||||
|
Read past the comm field's parens, since a process name may contain them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
||||||
|
return raw.rsplit(")", 1)[1].split()[19]
|
||||||
|
except (OSError, IndexError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
||||||
|
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
||||||
|
|
||||||
|
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
||||||
|
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
||||||
|
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
||||||
|
asserts.
|
||||||
|
|
||||||
|
Never raises: a launch must not be denied over bookkeeping. But it also never
|
||||||
|
fails silently — a missing record is exactly the kind of gap that made the
|
||||||
|
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
||||||
|
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(directory, 0o700)
|
||||||
|
framed = {
|
||||||
|
"seq": time.time_ns() // 1_000_000,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
**record,
|
||||||
|
}
|
||||||
|
path = directory / "events.ndjson"
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w") as handle:
|
||||||
|
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
||||||
|
except (OSError, ValueError, TypeError) as error:
|
||||||
|
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -94,8 +138,9 @@ def main(
|
|||||||
# silent pass and never folded into the generic registration-failure
|
# silent pass and never folded into the generic registration-failure
|
||||||
# branch.
|
# branch.
|
||||||
try:
|
try:
|
||||||
|
activation_capability = probe_activation_capability(source_environment)
|
||||||
assert_activation_capability_matches(
|
assert_activation_capability_matches(
|
||||||
probe_activation_capability(source_environment),
|
activation_capability,
|
||||||
expected_activation_capability,
|
expected_activation_capability,
|
||||||
)
|
)
|
||||||
except VersionCouplingError as version_error:
|
except VersionCouplingError as version_error:
|
||||||
@@ -128,6 +173,32 @@ def main(
|
|||||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
||||||
|
# config/provenance it knows; only this process knows the broker session id
|
||||||
|
# and the activation capability it just asserted. os.execvpe preserves the
|
||||||
|
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
||||||
|
# record. Never fatal — bookkeeping must not deny a launch — but never
|
||||||
|
# silent either.
|
||||||
|
_append_launch_record(
|
||||||
|
source_environment,
|
||||||
|
{
|
||||||
|
"kind": "lease.register",
|
||||||
|
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
||||||
|
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
||||||
|
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
||||||
|
# preserves it), which is a separate and still-useful fact.
|
||||||
|
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"runtime": arguments.runtime,
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"generation_file": str(generation_file),
|
||||||
|
"anchor_starttime": _self_starttime(),
|
||||||
|
"activation_capability": activation_capability,
|
||||||
|
"command": Path(command[0]).name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
environment = dict(source_environment)
|
environment = dict(source_environment)
|
||||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lease promotion client — the half the enforcement toolkit never shipped.
|
||||||
|
|
||||||
|
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
|
||||||
|
promotion half has no production caller anywhere in the package: as of 0.0.48,
|
||||||
|
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
|
||||||
|
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
|
||||||
|
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
|
||||||
|
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
|
||||||
|
gate nothing can satisfy.
|
||||||
|
|
||||||
|
THE PROTOCOL (``daemon.py:578-754``)
|
||||||
|
------------------------------------
|
||||||
|
1. ``begin_verification`` — broker revokes, mints a challenge, and returns the
|
||||||
|
exact ``receipt`` text the MODEL must emit
|
||||||
|
2. *the model emits that text verbatim as its ENTIRE latest message*
|
||||||
|
3. the runtime adapter ships that message to the daemon-owned observer socket
|
||||||
|
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
|
||||||
|
5. ``promote_lease`` -> ``VERIFIED``
|
||||||
|
|
||||||
|
THIS MODULE IMPLEMENTS 1, 4 AND 5 — NEVER 2
|
||||||
|
-------------------------------------------
|
||||||
|
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
|
||||||
|
``hmac.compare_digest`` against the exact minted string — explicitly "not a
|
||||||
|
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
|
||||||
|
live model that received the challenge in its context and echoed it exactly.
|
||||||
|
|
||||||
|
``receipt-observer-client.py`` will post ANY string as the latest assistant
|
||||||
|
message. A promotion client that posted its own receipt would satisfy the broker
|
||||||
|
while proving nothing — a gate-disabler indistinguishable from a working fix
|
||||||
|
unless someone looks for it. **This module never posts a receipt.** Emitting it
|
||||||
|
belongs to the runtime adapter, where a real model turn happens.
|
||||||
|
|
||||||
|
The construction binds the exact normative source bytes. ``h_source`` /
|
||||||
|
``h_payload`` are derived by the framework's own
|
||||||
|
``normative_fragments.build_payload`` rather than reimplemented: the broker
|
||||||
|
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
|
||||||
|
There must be exactly one implementation.
|
||||||
|
|
||||||
|
WHAT THE BINDING DOES *NOT* PROVE
|
||||||
|
---------------------------------
|
||||||
|
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
|
||||||
|
**It does not mean that**, and writing it down that way is how the belief spread.
|
||||||
|
The broker holds no reference copy of any normative source and never opens one;
|
||||||
|
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
|
||||||
|
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
|
||||||
|
Both sides of that comparison originate here, so it detects corruption in
|
||||||
|
transit and nothing else. What the binding actually asserts is "the client
|
||||||
|
claims these bytes, self-consistently".
|
||||||
|
|
||||||
|
Making it mean the stronger thing requires the broker to re-read the on-disk
|
||||||
|
sources itself, against a manifest the agent cannot rewrite — i.e. broker code
|
||||||
|
attestation under its own uid. Until then, do not cite a VERIFIED lease as
|
||||||
|
evidence of law integrity.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
lease_promote.py --begin # prints the receipt the MODEL must emit
|
||||||
|
lease_promote.py --complete <challenge> # after the adapter observed it
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# Isolated (`python -I`) adapter invocations must still import co-located
|
||||||
|
# framework modules; never depend on the caller's PYTHONPATH.
|
||||||
|
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||||
|
if _MODULE_DIRECTORY not in sys.path:
|
||||||
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||||
|
|
||||||
|
from normative_fragments import NormativeFragment, build_payload # noqa: E402
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
BROKER_TIMEOUT_SECONDS: Final = 3.0
|
||||||
|
SCHEMA_VERSION: Final = 1
|
||||||
|
MANIFEST_VERSION: Final = 1
|
||||||
|
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
|
||||||
|
DEFAULT_TTL_SECONDS: Final = 3600
|
||||||
|
|
||||||
|
# Normative sources whose exact bytes bind the lease, in binding order. Order is
|
||||||
|
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
|
||||||
|
# the derivation. Never fabricate a source that is not on disk.
|
||||||
|
FRAGMENT_SOURCES: Final = (
|
||||||
|
"CONSTITUTION.md",
|
||||||
|
"AGENTS.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"USER.md",
|
||||||
|
"STANDARDS.md",
|
||||||
|
"TOOLS.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
|
||||||
|
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
|
||||||
|
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
|
||||||
|
# missing one of these is broken, not minimal, so their absence is refused rather
|
||||||
|
# than silently dropped from the binding.
|
||||||
|
#
|
||||||
|
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
|
||||||
|
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
|
||||||
|
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
|
||||||
|
# only. Absence of those three is reported, not fatal.
|
||||||
|
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
|
||||||
|
|
||||||
|
|
||||||
|
class IncompleteBinding(RuntimeError):
|
||||||
|
"""A source that must bind this lease could not be read.
|
||||||
|
|
||||||
|
**Never downgrade this to a skip.** The broker recomputes the hashes from the
|
||||||
|
fragments it is sent, so an omitted fragment is internally consistent and
|
||||||
|
``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like
|
||||||
|
a complete one, and nothing downstream can tell the difference. Dropping an
|
||||||
|
unreadable source therefore does not degrade the binding, it forges a smaller
|
||||||
|
one. Fail here, where the omission is still visible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def mosaic_home() -> Path:
|
||||||
|
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
|
||||||
|
|
||||||
|
|
||||||
|
def broker_socket() -> Path:
|
||||||
|
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
|
||||||
|
if value:
|
||||||
|
return Path(value)
|
||||||
|
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||||
|
if runtime_dir:
|
||||||
|
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
|
||||||
|
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
|
||||||
|
|
||||||
|
|
||||||
|
def session_identity() -> tuple[str, int, str]:
|
||||||
|
"""Session id, CURRENT generation, runtime.
|
||||||
|
|
||||||
|
The generation file wins over the env var, matching ``lease_generation.py``.
|
||||||
|
Sending a generation HIGHER than the broker's would revoke this session's own
|
||||||
|
authority (``daemon.py:342-344``), so this never guesses.
|
||||||
|
"""
|
||||||
|
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
|
||||||
|
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
|
||||||
|
if state_file:
|
||||||
|
try:
|
||||||
|
return session_id, int(Path(state_file).read_text().strip()), runtime
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
|
||||||
|
|
||||||
|
|
||||||
|
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
|
||||||
|
"""Assemble the wire construction and derive its hashes with the sole builder."""
|
||||||
|
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
|
||||||
|
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
|
||||||
|
required = REQUIRED_SOURCES | {runtime_contract}
|
||||||
|
wire_fragments: list[dict[str, str]] = []
|
||||||
|
objects: list[NormativeFragment] = []
|
||||||
|
absent: list[str] = []
|
||||||
|
|
||||||
|
for source_id in sources:
|
||||||
|
try:
|
||||||
|
content = (mosaic_home() / source_id).read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Genuinely not on disk. Legitimate only for operator-owned sources.
|
||||||
|
if source_id in required:
|
||||||
|
raise IncompleteBinding(
|
||||||
|
f"required normative source is absent: {source_id}"
|
||||||
|
) from None
|
||||||
|
absent.append(source_id)
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
|
||||||
|
# That is an anomaly for EVERY source, optional ones included: an
|
||||||
|
# unreadable file is not an un-configured one, and treating it as
|
||||||
|
# absent is what lets a permission change quietly shrink the law.
|
||||||
|
raise IncompleteBinding(
|
||||||
|
f"normative source is present but unreadable: {source_id} "
|
||||||
|
f"({type(exc).__name__})"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
digest = hashlib.sha256(content).hexdigest()
|
||||||
|
wire_fragments.append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||||
|
"expected_sha256": digest,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
objects.append(NormativeFragment(source_id, content, digest))
|
||||||
|
|
||||||
|
if not wire_fragments:
|
||||||
|
raise IncompleteBinding("no normative sources found — refusing an empty binding")
|
||||||
|
|
||||||
|
# Absence is legitimate here but never invisible. The omission is already
|
||||||
|
# baked into h_source (the framed source sequence differs), but nothing
|
||||||
|
# compares h_source to an expected value, so this line is the only place a
|
||||||
|
# human learns the binding was narrower than the full set.
|
||||||
|
if absent:
|
||||||
|
print(
|
||||||
|
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = build_payload(
|
||||||
|
manifest_version=MANIFEST_VERSION,
|
||||||
|
generator_version=GENERATOR_VERSION,
|
||||||
|
fragments=objects,
|
||||||
|
)
|
||||||
|
if result.injectionDecision != "ACCEPTED" or not result.promotion:
|
||||||
|
raise RuntimeError(f"construction refused locally: {result.source_reason}")
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"manifest_version": MANIFEST_VERSION,
|
||||||
|
"generator_version": GENERATOR_VERSION,
|
||||||
|
"fragments": wire_fragments,
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def broker_request(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
||||||
|
if len(raw) > MAX_FRAME:
|
||||||
|
raise ValueError(
|
||||||
|
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
|
||||||
|
)
|
||||||
|
response = bytearray()
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
|
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||||
|
connection.connect(str(broker_socket()))
|
||||||
|
connection.sendall(raw)
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
while len(response) <= MAX_FRAME:
|
||||||
|
chunk = connection.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||||
|
raise ValueError("invalid broker reply")
|
||||||
|
value = json.loads(response)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("invalid broker reply")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def begin(
|
||||||
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||||
|
compaction_epoch: int = 0,
|
||||||
|
request_epoch: int = 0,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
|
||||||
|
session_id, generation, runtime = session_identity()
|
||||||
|
construction, derived = build_construction(runtime)
|
||||||
|
return broker_request(
|
||||||
|
{
|
||||||
|
"action": "begin_verification",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"runtime": runtime,
|
||||||
|
"ttl_seconds": ttl_seconds,
|
||||||
|
"binding": {
|
||||||
|
"compaction_epoch": compaction_epoch,
|
||||||
|
"request_epoch": request_epoch,
|
||||||
|
"h_source": derived.h_source,
|
||||||
|
"h_payload": derived.h_payload,
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
},
|
||||||
|
"construction": construction,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def complete(challenge: str) -> dict[str, object]:
|
||||||
|
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
|
||||||
|
shipped it to the observer socket."""
|
||||||
|
session_id, generation, _ = session_identity()
|
||||||
|
observed = broker_request(
|
||||||
|
{
|
||||||
|
"action": "observe_receipt",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"receipt_challenge": challenge,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
||||||
|
return {"stage": "observe_receipt", **observed}
|
||||||
|
promoted = broker_request(
|
||||||
|
{
|
||||||
|
"action": "promote_lease",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"receipt_challenge": challenge,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"stage": "promote_lease", **promoted}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument(
|
||||||
|
"--begin",
|
||||||
|
action="store_true",
|
||||||
|
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
|
||||||
|
)
|
||||||
|
group.add_argument(
|
||||||
|
"--complete",
|
||||||
|
metavar="CHALLENGE",
|
||||||
|
help="observe the emitted receipt and promote the lease",
|
||||||
|
)
|
||||||
|
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
|
||||||
|
arguments = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if arguments.begin:
|
||||||
|
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
|
||||||
|
else:
|
||||||
|
print(json.dumps(complete(arguments.complete), indent=2))
|
||||||
|
except KeyError as exc:
|
||||||
|
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, TextIO
|
||||||
|
|
||||||
|
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||||
|
if _MODULE_DIRECTORY not in sys.path:
|
||||||
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||||
|
|
||||||
|
from receipt_challenge import receipt_for # noqa: E402
|
||||||
|
|
||||||
|
_observer_spec = importlib.util.spec_from_file_location(
|
||||||
|
"mosaic_receipt_observer_client", Path(__file__).resolve().with_name("receipt-observer-client.py")
|
||||||
|
)
|
||||||
|
if _observer_spec is None or _observer_spec.loader is None:
|
||||||
|
raise RuntimeError("unable to load receipt observer client")
|
||||||
|
_observer_module = importlib.util.module_from_spec(_observer_spec)
|
||||||
|
_observer_spec.loader.exec_module(_observer_module)
|
||||||
|
observer_request = _observer_module.observer_request
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
|
||||||
|
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||||
|
PROMOTION_PROMPT: Final = "/mosaic-promote"
|
||||||
|
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||||
|
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||||
|
AUTHORIZATION_DIRECTORY: Final = "authorizations"
|
||||||
|
AUTHORIZATION_TTL_SECONDS: Final = 60
|
||||||
|
LEASE_TTL_SECONDS: Final = 60 * 60
|
||||||
|
LOCK_FILE: Final = "promotion.lock"
|
||||||
|
RESULT_FILE: Final = "last-result.json"
|
||||||
|
EXPECTED_BEGIN_KEYS: Final = frozenset(
|
||||||
|
{"ok", "state", "receipt_challenge", "receipt", "binding"}
|
||||||
|
)
|
||||||
|
EXPECTED_BINDING_KEYS: Final = frozenset(
|
||||||
|
{
|
||||||
|
"compaction_epoch",
|
||||||
|
"request_epoch",
|
||||||
|
"h_source",
|
||||||
|
"h_payload",
|
||||||
|
"runtime_generation",
|
||||||
|
"schema_version",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PromotionAlreadyInProgress(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||||
|
value: dict[str, object] = {}
|
||||||
|
for key, item in pairs:
|
||||||
|
if key in value:
|
||||||
|
raise ValueError("duplicate promoter JSON key")
|
||||||
|
value[key] = item
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def read_hook_input(stream: object) -> dict[str, object]:
|
||||||
|
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||||
|
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||||
|
raise ValueError("invalid UserPromptSubmit input")
|
||||||
|
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("invalid UserPromptSubmit input")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def emit_context(stream: TextIO, message: str) -> None:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "UserPromptSubmit",
|
||||||
|
"additionalContext": message,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stream,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
stream.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||||
|
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||||
|
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
if not runtime_dir.is_absolute():
|
||||||
|
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||||
|
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||||
|
raise ValueError("invalid lease session id")
|
||||||
|
return runtime_dir, f"pending-{session_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def open_pending_directory(runtime_dir: Path) -> int:
|
||||||
|
directory_flags = (
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_DIRECTORY", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||||
|
try:
|
||||||
|
runtime_metadata = os.fstat(runtime_descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||||
|
or runtime_metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||||
|
):
|
||||||
|
raise ValueError("unsafe XDG runtime directory")
|
||||||
|
try:
|
||||||
|
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(runtime_descriptor)
|
||||||
|
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||||
|
):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError("unsafe promotion pending directory")
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock(directory_descriptor: int) -> int:
|
||||||
|
flags = (
|
||||||
|
os.O_RDWR
|
||||||
|
| os.O_CREAT
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError("unsafe promotion lock file")
|
||||||
|
try:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError as error:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise PromotionAlreadyInProgress() from error
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
|
||||||
|
cutoff = current_time - PENDING_MAX_AGE_SECONDS
|
||||||
|
removed = False
|
||||||
|
with os.scandir(directory_descriptor) as entries:
|
||||||
|
for candidate in entries:
|
||||||
|
if not (
|
||||||
|
candidate.name.startswith("pending-")
|
||||||
|
or candidate.name.startswith(".pending-")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
metadata = candidate.stat(follow_symlinks=False)
|
||||||
|
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
os.unlink(candidate.name, dir_fd=directory_descriptor)
|
||||||
|
removed = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if removed:
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_authorization(directory_descriptor: int, session_id: str, wall_clock: float) -> str | None:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
authorization_descriptor = os.open(AUTHORIZATION_DIRECTORY, flags, dir_fd=directory_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(authorization_descriptor)
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
||||||
|
raise ValueError("unsafe promotion authorization directory")
|
||||||
|
name = f"{session_id}.auth"
|
||||||
|
try:
|
||||||
|
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=authorization_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
token_metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(token_metadata.st_mode) or token_metadata.st_uid != os.getuid() or stat.S_IMODE(token_metadata.st_mode) != 0o600 or token_metadata.st_size <= 0 or token_metadata.st_size > MAX_FRAME:
|
||||||
|
raise ValueError("unsafe promotion authorization")
|
||||||
|
raw = os.read(descriptor, MAX_FRAME + 1)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
os.unlink(name, dir_fd=authorization_descriptor)
|
||||||
|
os.fsync(authorization_descriptor)
|
||||||
|
token = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||||
|
if not isinstance(token, dict) or set(token) != {"nonce", "seat", "session_id", "expires_at", "ts"}:
|
||||||
|
return None
|
||||||
|
nonce = token.get("nonce")
|
||||||
|
expires_at = token.get("expires_at")
|
||||||
|
issued_at = token.get("ts")
|
||||||
|
if token.get("session_id") != session_id or not isinstance(token.get("seat"), str) or not isinstance(nonce, str) or len(nonce) != 64 or any(char not in "0123456789abcdef" for char in nonce) or type(expires_at) not in (int, float) or type(issued_at) not in (int, float) or expires_at <= wall_clock or expires_at > issued_at + AUTHORIZATION_TTL_SECONDS:
|
||||||
|
return None
|
||||||
|
return nonce
|
||||||
|
finally:
|
||||||
|
os.close(authorization_descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def write_result(directory_descriptor: int, attempt_id: str, verified: bool, reason: str | None, session_id: str, wall_clock: float) -> None:
|
||||||
|
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
||||||
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_descriptor)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||||
|
json.dump({"attempt_id": attempt_id, "expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None, "reason": reason, "session_id": session_id, "ts": wall_clock, "verified": verified}, stream, separators=(",", ":"), sort_keys=True)
|
||||||
|
stream.flush(); os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, RESULT_FILE, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor)
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
|
||||||
|
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
|
||||||
|
flags = (
|
||||||
|
os.O_WRONLY
|
||||||
|
| os.O_CREAT
|
||||||
|
| os.O_EXCL
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||||
|
stream.write(challenge)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(
|
||||||
|
temporary,
|
||||||
|
name,
|
||||||
|
src_dir_fd=directory_descriptor,
|
||||||
|
dst_dir_fd=directory_descriptor,
|
||||||
|
)
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_begin_reply(
|
||||||
|
completed: subprocess.CompletedProcess[str],
|
||||||
|
) -> tuple[str, dict[str, object] | None]:
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return f"PROMOTER_EXIT_{completed.returncode}", None
|
||||||
|
try:
|
||||||
|
value = json.loads(
|
||||||
|
completed.stdout,
|
||||||
|
object_pairs_hook=reject_duplicate_json_keys,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
if value.get("ok") is False and set(value) == {"ok", "code"}:
|
||||||
|
code = value.get("code")
|
||||||
|
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
|
||||||
|
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
if value.get("state") != "PENDING_VERIFICATION":
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
challenge = value.get("receipt_challenge")
|
||||||
|
receipt = value.get("receipt")
|
||||||
|
binding = value.get("binding")
|
||||||
|
if (
|
||||||
|
not isinstance(challenge, str)
|
||||||
|
or len(challenge) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in challenge)
|
||||||
|
or not isinstance(receipt, str)
|
||||||
|
or not isinstance(binding, dict)
|
||||||
|
or set(binding) != EXPECTED_BINDING_KEYS
|
||||||
|
):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
integer_fields = (
|
||||||
|
"compaction_epoch",
|
||||||
|
"request_epoch",
|
||||||
|
"runtime_generation",
|
||||||
|
"schema_version",
|
||||||
|
)
|
||||||
|
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
if not all(
|
||||||
|
isinstance(binding.get(field), str)
|
||||||
|
and len(binding[field]) == 64
|
||||||
|
and all(character in "0123456789abcdef" for character in binding[field])
|
||||||
|
for field in ("h_source", "h_payload")
|
||||||
|
):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
if not secrets.compare_digest(
|
||||||
|
receipt.encode("utf-8"),
|
||||||
|
receipt_for(challenge, binding).encode("utf-8"),
|
||||||
|
):
|
||||||
|
return "INVALID_PROMOTER_REPLY", None
|
||||||
|
return "", value
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
stdin: object | None = None,
|
||||||
|
stdout: TextIO | None = None,
|
||||||
|
stderr: TextIO | None = None,
|
||||||
|
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||||
|
now: Callable[[], float] = time.time,
|
||||||
|
) -> int:
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
|
input_stream = sys.stdin if stdin is None else stdin
|
||||||
|
output_stream = sys.stdout if stdout is None else stdout
|
||||||
|
error_stream = sys.stderr if stderr is None else stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_input = read_hook_input(input_stream)
|
||||||
|
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
|
||||||
|
return 0
|
||||||
|
if hook_input.get("prompt") != PROMOTION_PROMPT:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
directory_descriptor: int | None = None
|
||||||
|
lock_descriptor: int | None = None
|
||||||
|
try:
|
||||||
|
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||||
|
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
directory_descriptor = open_pending_directory(runtime_dir)
|
||||||
|
lock_descriptor = acquire_lock(directory_descriptor)
|
||||||
|
wall_clock = now()
|
||||||
|
nonce = consume_authorization(directory_descriptor, session_id, wall_clock)
|
||||||
|
if nonce is None:
|
||||||
|
write_result(directory_descriptor, "0" * 64, False, "NOT_AUTHORIZED", session_id, wall_clock)
|
||||||
|
print("Mosaic promotion denied: NOT_AUTHORIZED.", file=error_stream)
|
||||||
|
return 0
|
||||||
|
sweep_stale_pending(directory_descriptor, wall_clock)
|
||||||
|
completed = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
||||||
|
code, reply = parse_begin_reply(completed)
|
||||||
|
if code or reply is None:
|
||||||
|
write_result(directory_descriptor, nonce, False, code or "PROMOTION_BEGIN_FAILED", session_id, now())
|
||||||
|
return 0
|
||||||
|
challenge = str(reply["receipt_challenge"])
|
||||||
|
observation = observer_request(
|
||||||
|
Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]),
|
||||||
|
{"action": "record_runtime_observation", "session_id": session_id, "runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]), "runtime": "claude", "latest_assistant_message": reply["receipt"]},
|
||||||
|
)
|
||||||
|
if set(observation) != {"ok"} or observation.get("ok") is not True:
|
||||||
|
write_result(directory_descriptor, challenge, False, "OBSERVATION_REJECTED", session_id, now())
|
||||||
|
return 0
|
||||||
|
completion = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--complete", challenge], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
||||||
|
try:
|
||||||
|
outcome = json.loads(completion.stdout, object_pairs_hook=reject_duplicate_json_keys)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
outcome = None
|
||||||
|
if completion.returncode == 0 and isinstance(outcome, dict) and outcome.get("stage") == "promote_lease" and outcome.get("ok") is True and outcome.get("state") == "VERIFIED":
|
||||||
|
write_result(directory_descriptor, challenge, True, None, session_id, now())
|
||||||
|
else:
|
||||||
|
reason = outcome.get("code") if isinstance(outcome, dict) and isinstance(outcome.get("code"), str) else "PROMOTION_INCOMPLETE"
|
||||||
|
write_result(directory_descriptor, challenge, False, reason, session_id, now())
|
||||||
|
except PromotionAlreadyInProgress:
|
||||||
|
print("Mosaic promotion denied: PROMOTION_ALREADY_IN_PROGRESS.", file=error_stream)
|
||||||
|
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
||||||
|
finally:
|
||||||
|
if lock_descriptor is not None:
|
||||||
|
os.close(lock_descriptor)
|
||||||
|
if directory_descriptor is not None:
|
||||||
|
os.close(directory_descriptor)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Claude Stop hook that completes a pending operator-triggered promotion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, NamedTuple, TextIO
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||||
|
LEASE_TTL_SECONDS: Final = 60 * 60
|
||||||
|
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||||
|
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||||
|
LOCK_FILE: Final = "promotion.lock"
|
||||||
|
RESULT_FILE: Final = "last-result.json"
|
||||||
|
TERMINAL_FAILURE_CODES: Final = frozenset(
|
||||||
|
{
|
||||||
|
"RECEIPT_REPLAY",
|
||||||
|
"RECEIPT_MISMATCH",
|
||||||
|
"INVALID_LEASE_TRANSITION",
|
||||||
|
"PROMOTION_TOKEN_INVALID",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingChallenge(NamedTuple):
|
||||||
|
value: str
|
||||||
|
device: int
|
||||||
|
inode: int
|
||||||
|
|
||||||
|
|
||||||
|
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||||
|
value: dict[str, object] = {}
|
||||||
|
for key, item in pairs:
|
||||||
|
if key in value:
|
||||||
|
raise ValueError("duplicate promoter JSON key")
|
||||||
|
value[key] = item
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||||
|
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||||
|
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
if not runtime_dir.is_absolute():
|
||||||
|
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||||
|
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||||
|
raise ValueError("invalid lease session id")
|
||||||
|
return runtime_dir, f"pending-{session_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def open_pending_directory(runtime_dir: Path) -> int | None:
|
||||||
|
directory_flags = (
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_DIRECTORY", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
runtime_metadata = os.fstat(runtime_descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||||
|
or runtime_metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||||
|
):
|
||||||
|
raise ValueError("unsafe XDG runtime directory")
|
||||||
|
try:
|
||||||
|
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.close(runtime_descriptor)
|
||||||
|
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||||
|
):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError("unsafe promotion pending directory")
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock(directory_descriptor: int) -> int:
|
||||||
|
flags = (
|
||||||
|
os.O_RDWR
|
||||||
|
| os.O_CREAT
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError("unsafe promotion lock file")
|
||||||
|
try:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||||
|
or metadata.st_size <= 0
|
||||||
|
or metadata.st_size > MAX_FRAME
|
||||||
|
):
|
||||||
|
raise ValueError("unsafe promotion pending file")
|
||||||
|
raw = os.read(descriptor, MAX_FRAME + 1)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
if len(raw) > MAX_FRAME:
|
||||||
|
raise ValueError("oversized promotion challenge")
|
||||||
|
challenge = raw.decode("utf-8")
|
||||||
|
if (
|
||||||
|
len(challenge) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in challenge)
|
||||||
|
):
|
||||||
|
raise ValueError("invalid promotion challenge")
|
||||||
|
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
|
||||||
|
|
||||||
|
|
||||||
|
def write_result(
|
||||||
|
directory_descriptor: int,
|
||||||
|
attempt_id: str,
|
||||||
|
verified: bool,
|
||||||
|
reason: str | None,
|
||||||
|
session_id: str,
|
||||||
|
wall_clock: float,
|
||||||
|
) -> None:
|
||||||
|
result = {
|
||||||
|
"attempt_id": attempt_id,
|
||||||
|
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
|
||||||
|
"reason": reason,
|
||||||
|
"session_id": session_id,
|
||||||
|
"ts": wall_clock,
|
||||||
|
"verified": verified,
|
||||||
|
}
|
||||||
|
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
||||||
|
flags = (
|
||||||
|
os.O_WRONLY
|
||||||
|
| os.O_CREAT
|
||||||
|
| os.O_EXCL
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||||
|
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(
|
||||||
|
temporary,
|
||||||
|
RESULT_FILE,
|
||||||
|
src_dir_fd=directory_descriptor,
|
||||||
|
dst_dir_fd=directory_descriptor,
|
||||||
|
)
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_pending_if_unchanged(
|
||||||
|
directory_descriptor: int,
|
||||||
|
name: str,
|
||||||
|
pending: PendingChallenge,
|
||||||
|
error_stream: TextIO,
|
||||||
|
) -> None:
|
||||||
|
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
|
||||||
|
try:
|
||||||
|
os.rename(
|
||||||
|
name,
|
||||||
|
quarantine,
|
||||||
|
src_dir_fd=directory_descriptor,
|
||||||
|
dst_dir_fd=directory_descriptor,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError as error:
|
||||||
|
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
moved = os.stat(
|
||||||
|
quarantine,
|
||||||
|
dir_fd=directory_descriptor,
|
||||||
|
follow_symlinks=False,
|
||||||
|
)
|
||||||
|
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
|
||||||
|
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
|
||||||
|
try:
|
||||||
|
os.link(
|
||||||
|
quarantine,
|
||||||
|
name,
|
||||||
|
src_dir_fd=directory_descriptor,
|
||||||
|
dst_dir_fd=directory_descriptor,
|
||||||
|
follow_symlinks=False,
|
||||||
|
)
|
||||||
|
except FileExistsError:
|
||||||
|
print(
|
||||||
|
f"Mosaic promotion preserved replacement as {quarantine}.",
|
||||||
|
file=error_stream,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||||
|
os.fsync(directory_descriptor)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(
|
||||||
|
completed.stdout,
|
||||||
|
object_pairs_hook=reject_duplicate_json_keys,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
if set(value) == {"stage", "ok", "state"}:
|
||||||
|
if (
|
||||||
|
value.get("stage") == "promote_lease"
|
||||||
|
and value.get("ok") is True
|
||||||
|
and value.get("state") == "VERIFIED"
|
||||||
|
):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
if set(value) == {"stage", "ok", "code"}:
|
||||||
|
if (
|
||||||
|
value.get("stage") in {"observe_receipt", "promote_lease"}
|
||||||
|
and value.get("ok") is False
|
||||||
|
and isinstance(value.get("code"), str)
|
||||||
|
and value.get("code")
|
||||||
|
):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
stderr: TextIO | None = None,
|
||||||
|
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||||
|
now: Callable[[], float] = time.time,
|
||||||
|
) -> int:
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
|
error_stream = sys.stderr if stderr is None else stderr
|
||||||
|
directory_descriptor: int | None = None
|
||||||
|
lock_descriptor: int | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||||
|
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
directory_descriptor = open_pending_directory(runtime_dir)
|
||||||
|
if directory_descriptor is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
lock_descriptor = acquire_lock(directory_descriptor)
|
||||||
|
except (BlockingIOError, FileNotFoundError):
|
||||||
|
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
|
||||||
|
return 0
|
||||||
|
pending = read_pending(directory_descriptor, pending_name)
|
||||||
|
if pending is None:
|
||||||
|
return 0
|
||||||
|
completed = run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
|
"-B",
|
||||||
|
str(PROMOTER),
|
||||||
|
"--complete",
|
||||||
|
pending.value,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=dict(source_environment),
|
||||||
|
timeout=PROMOTER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
reply = parse_reply(completed)
|
||||||
|
if reply is not None and reply.get("ok") is True:
|
||||||
|
write_result(directory_descriptor, pending.value, True, None, session_id, now())
|
||||||
|
delete_pending_if_unchanged(
|
||||||
|
directory_descriptor,
|
||||||
|
pending_name,
|
||||||
|
pending,
|
||||||
|
error_stream,
|
||||||
|
)
|
||||||
|
print("Mosaic lease promotion completed.", file=error_stream)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if reply is not None:
|
||||||
|
code = str(reply["code"])
|
||||||
|
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
|
||||||
|
if code in TERMINAL_FAILURE_CODES:
|
||||||
|
write_result(directory_descriptor, pending.value, False, code, session_id, now())
|
||||||
|
delete_pending_if_unchanged(
|
||||||
|
directory_descriptor,
|
||||||
|
pending_name,
|
||||||
|
pending,
|
||||||
|
error_stream,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
|
||||||
|
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
|
||||||
|
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
|
||||||
|
finally:
|
||||||
|
if lock_descriptor is not None:
|
||||||
|
os.close(lock_descriptor)
|
||||||
|
if directory_descriptor is not None:
|
||||||
|
os.close(directory_descriptor)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -22,13 +22,23 @@ from typing import Final
|
|||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
||||||
|
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||||
|
value: dict[str, object] = {}
|
||||||
|
for key, item in pairs:
|
||||||
|
if key in value:
|
||||||
|
raise ValueError("duplicate observer JSON key")
|
||||||
|
value[key] = item
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def read_json(stream: object) -> dict[str, object]:
|
def read_json(stream: object) -> dict[str, object]:
|
||||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
value = json.loads(raw)
|
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
return value
|
return value
|
||||||
@@ -100,9 +110,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
response.extend(chunk)
|
response.extend(chunk)
|
||||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
value = json.loads(response)
|
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
return value
|
return value
|
||||||
@@ -119,6 +129,11 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
if arguments.runtime == "claude":
|
if arguments.runtime == "claude":
|
||||||
if not arguments.latest_entry:
|
if not arguments.latest_entry:
|
||||||
raise ValueError("Claude observer requires --latest-entry")
|
raise ValueError("Claude observer requires --latest-entry")
|
||||||
|
if "last_assistant_message" in source:
|
||||||
|
message = source["last_assistant_message"]
|
||||||
|
if not isinstance(message, str):
|
||||||
|
raise ValueError("invalid Claude observer input")
|
||||||
|
else:
|
||||||
message = claude_latest_entry(source)
|
message = claude_latest_entry(source)
|
||||||
else:
|
else:
|
||||||
if arguments.latest_entry:
|
if arguments.latest_entry:
|
||||||
@@ -133,10 +148,18 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
"runtime": arguments.runtime,
|
"runtime": arguments.runtime,
|
||||||
"latest_assistant_message": message,
|
"latest_assistant_message": message,
|
||||||
})
|
})
|
||||||
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||||
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
return 0 if reply == {"ok": True} else 2
|
if set(reply) == {"ok"} and reply.get("ok") is True:
|
||||||
|
return 0
|
||||||
|
if (
|
||||||
|
set(reply) == {"ok", "code"}
|
||||||
|
and reply.get("ok") is False
|
||||||
|
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
|
||||||
|
):
|
||||||
|
return 0
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
|
|||||||
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
||||||
|
|
||||||
expected = receipt_for(challenge, binding)
|
expected = receipt_for(challenge, binding)
|
||||||
return hmac.compare_digest(message, expected)
|
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def latest_assistant_digest(message: str) -> str:
|
def latest_assistant_digest(message: str) -> str:
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
|||||||
# loop — which would make the control a false negative. A root dotfile is
|
# loop — which would make the control a false negative. A root dotfile is
|
||||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||||
# A bash signal trap that merely returns lets the script continue past the
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
# success. The earlier test used a child cp shim to signal its parent, making
|
||||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||||
# the trap and shows the buggy resume-to-success.
|
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||||
make_term_shim() {
|
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||||
local dir="$1"
|
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||||
cat > "$dir/cp" <<SHIM
|
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||||
#!/usr/bin/env bash
|
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||||
dest="\${@: -1}"
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
case "\$dest" in
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
*/$POISON_REL)
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
make_signal_installer() {
|
||||||
esac
|
local output="$1" handler="$2"
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
SHIM
|
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||||
chmod +x "$dir/cp"
|
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||||
|
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||||
|
local inject_close=' fi'
|
||||||
|
|
||||||
|
if ! awk \
|
||||||
|
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||||
|
-v handler="$handler" -v inject_open="$inject_open" \
|
||||||
|
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||||
|
$0 == target_cp {
|
||||||
|
print inject_open
|
||||||
|
print inject_kill
|
||||||
|
print inject_close
|
||||||
|
injection_sites++
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
$0 == target_trap {
|
||||||
|
print handler
|
||||||
|
handler_sites++
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||||
|
}
|
||||||
|
' "$INSTALL" > "$output"; then
|
||||||
|
rm -f "$output"
|
||||||
|
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$output"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||||
|
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||||
|
signal_fixture_ready() {
|
||||||
|
local fixture="$1" expected_handler="$2"
|
||||||
|
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||||
|
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||||
|
&& grep -Fqx "$expected_handler" "$fixture"
|
||||||
|
}
|
||||||
|
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||||
|
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||||
|
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"signaled_fixture_ready"
|
||||||
|
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"noexit_fixture_ready"
|
||||||
|
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||||
|
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||||
|
|
||||||
|
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||||
|
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||||
run_signal_upgrade() {
|
run_signal_upgrade() {
|
||||||
local installer="$1" H OUT SHIM rc
|
local installer="$1" H OUT rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
H=$(mktemp -d); OUT=$(mktemp)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
make_term_shim "$SHIM"
|
|
||||||
set +e
|
set +e
|
||||||
PATH="$SHIM:$ORIG_PATH" \
|
PATH="$ORIG_PATH" \
|
||||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
rm -rf "$SHIM"
|
|
||||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
}
|
}
|
||||||
|
|
||||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||||
|
chk "[signal] TERM handler observable fires exactly once" \
|
||||||
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
|||||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
chk "[control] TERM handler observable fires exactly once" \
|
||||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||||
"$INSTALL" > "$NOEXIT"
|
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||||
chk "[control] the exit-strip actually changed the installer" \
|
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
|
||||||
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
|||||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||||
|
|
||||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -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-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"
|
"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/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_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-pr-merge-message-field.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/_scripts/test-mosaic-init-rce.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:*",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { registerAgentCommand } from './commands/agent.js';
|
|||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
|
import { registerPromoteCommand } from './commands/promote.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
@@ -370,6 +371,7 @@ registerInteractionCommand(program);
|
|||||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
registerPromoteCommand(program);
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
// ─── config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
appendFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
|||||||
pi: 'Pi',
|
pi: 'Pi',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Harness home isolation ──────────────────────────────────────────────────
|
||||||
|
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
||||||
|
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
||||||
|
// keeps its own config AND its own auth, and stays a working break-glass no
|
||||||
|
// matter what mosaic does to its own tree.
|
||||||
|
//
|
||||||
|
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
||||||
|
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
||||||
|
// neither overwrite nor prune them. Overwrite-mode install still would.
|
||||||
|
//
|
||||||
|
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
||||||
|
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
||||||
|
// three: it also relocates XDG lookups for anything opencode spawns.
|
||||||
|
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
||||||
|
claude: 'CLAUDE_CONFIG_DIR',
|
||||||
|
pi: 'PI_CODING_AGENT_DIR',
|
||||||
|
codex: 'CODEX_HOME',
|
||||||
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||||
|
function harnessHome(runtime: RuntimeName): string {
|
||||||
|
return join(MOSAIC_HOME, `.${runtime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
||||||
|
* created on demand so a first launch does not fail on a missing path.
|
||||||
|
*/
|
||||||
|
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
||||||
|
const key = HARNESS_HOME_ENV[runtime];
|
||||||
|
if (!key) return {};
|
||||||
|
const home = harnessHome(runtime);
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
return { [key]: home };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
||||||
|
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
||||||
|
// actually launched with, written before exec. No model involvement, no opt-out.
|
||||||
|
//
|
||||||
|
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
||||||
|
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
||||||
|
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
||||||
|
// disproved only by the parent process's argv and only because the parent had
|
||||||
|
// not yet exited. A record written before exec is the only place this survives.
|
||||||
|
//
|
||||||
|
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
||||||
|
// operator-classified in framework-manifest.txt and already covered by
|
||||||
|
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
||||||
|
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
||||||
|
// launch-runtime.py appends the matching `lease.register` event.
|
||||||
|
//
|
||||||
|
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
||||||
|
// oversized argv values (the composed system prompt) become a digest + length.
|
||||||
|
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
||||||
|
|
||||||
|
const CLI_VERSION: string | null = (() => {
|
||||||
|
try {
|
||||||
|
// Resolved RELATIVELY: the package `exports` map does not expose
|
||||||
|
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
||||||
|
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
||||||
|
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
interface NormativeFragmentDigest {
|
||||||
|
source_id: string;
|
||||||
|
sha256: string | null;
|
||||||
|
bytes: number | null;
|
||||||
|
missing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(value: string | Buffer): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the normative sources injected into the agent. This is "what the agent
|
||||||
|
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
||||||
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||||
|
* a matter of judgement.
|
||||||
|
*/
|
||||||
|
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
||||||
|
const candidates: Array<[string, string]> = [
|
||||||
|
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
||||||
|
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
||||||
|
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
||||||
|
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
||||||
|
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
||||||
|
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
||||||
|
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
||||||
|
];
|
||||||
|
return candidates.map(([sourceId, path]) => {
|
||||||
|
try {
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
||||||
|
} catch {
|
||||||
|
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** argv with oversized values replaced by a digest, so the record stays small
|
||||||
|
* and never inlines injected content verbatim. */
|
||||||
|
function redactArgv(argv: string[]): string[] {
|
||||||
|
return argv.map((a) =>
|
||||||
|
typeof a === 'string' && a.length > 256
|
||||||
|
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
||||||
|
: a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
|
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||||
|
const record = {
|
||||||
|
seq: Date.now(),
|
||||||
|
kind: 'session.launch',
|
||||||
|
launch_id: launchId,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
host: hostname(),
|
||||||
|
pid: process.pid,
|
||||||
|
runtime,
|
||||||
|
mode: yolo ? 'yolo' : 'normal',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
cli_version: CLI_VERSION,
|
||||||
|
config_home: harnessHome(runtime),
|
||||||
|
config_home_isolated: true,
|
||||||
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
|
argv: redactArgv(cliArgs),
|
||||||
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
|
// names only — values are never recorded
|
||||||
|
mosaic_env_present: Object.keys(process.env)
|
||||||
|
.filter((k) => k.startsWith('MOSAIC_'))
|
||||||
|
.sort(),
|
||||||
|
};
|
||||||
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Never block a launch on bookkeeping — but never fail silently either.
|
||||||
|
console.error(
|
||||||
|
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function checkMosaicHome(): void {
|
function checkMosaicHome(): void {
|
||||||
@@ -105,11 +264,11 @@ interface SettingsAudit {
|
|||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
||||||
return { warnings };
|
return { warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +720,9 @@ function skillRealPath(dir: string): string {
|
|||||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||||
* skills dir and the project-local one relative to the launch cwd. */
|
* skills dir and the project-local one relative to the launch cwd. */
|
||||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||||
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
||||||
|
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
||||||
|
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||||
@@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
||||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('codex', cliArgs);
|
recordLaunch('codex', cliArgs, yolo);
|
||||||
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
execRuntime('opencode', args);
|
recordLaunch('opencode', args, yolo);
|
||||||
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('pi', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -835,6 +1001,7 @@ function execLeaseGatedRuntime(
|
|||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
|
...harnessEnv(runtime),
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user