Compare commits

..
Author SHA1 Message Date
Mos e00cc475a2 Merge pull request 'P3 — Typed SPA chat (Phase P webUI)' (#1151) from feat/webui-p3-chat into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 22:57:07 +00:00
shaggy (mosaic-dev box) 7d84e4ee03 fix(gateway): sanitize raw exceptions in /mcp status + /reload sources (P3 re-review#5 blocker)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 16:58:07 -05:00
shaggy (mosaic-dev box) 4aaf41dd1a fix(web,gateway): close P3 re-review#4 findings — sanitize all executor catches; lock pre-start turn boundary (wire turnId deferred) 2026-08-10 16:07:12 -05:00
mos-dt-0 bf32f29acd fix(ci): make queue guard purpose-sensitive (#1148)
ci/woodpecker/push/publish Pipeline failed
2026-08-10 20:54:09 +00:00
Jason Woltje 1655b1579a Move deploy and briefs into docs folder. Remove old files.
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:44:19 -05:00
Jason Woltje e478a359eb Move briefs directory inside the docs
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:42:04 -05:00
Jason Woltje 76e4242cb1 Reformat Requirements block
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:40:18 -05:00
Jason Woltje 00eb216480 docs(agents): consolidate project guidance 2026-08-10 14:27:59 -05:00
shaggy (mosaic-dev box) d46a2d675a fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps 2026-08-10 14:21:52 -05:00
shaggy (mosaic-dev box) 48bb19310d fix(web): close P3 chat re-review findings 2026-08-10 01:58:27 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 caebf9ef70 fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 00:24:20 -05:00
shaggy (mosaic-dev box) b2e005f2b4 feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed
Socket.IO /chat contract from @mosaicstack/types, replacing the /chat
placeholder behind AuthGuard. Surfaces message:ack (with an accessible
status), agent:start, streamed agent:text/agent:thinking, tool start/end
status, agent:end with usage, session:info (thinking controls + routing
decision), commands:manifest, command:result, command:approval (with a
one-time approved-run affordance), system:reload (refreshing the rendered
manifest), and error, and emits message/abort/set:thinking/command:execute/
command:approve with exact payloads.

The gateway does not guarantee message:ack is the first event for a new
conversation (session:info, and error on auth/session-creation failure, can
both arrive first) — conversation-scoped events now adopt the conversation
from whichever scoped event names it first while a send is pending, then
filter everything else against that established conversation. A typed error
stops streaming instead of leaving Stop stuck active; agent:end no longer
appends an empty assistant turn when there is no text or thinking; and a
second message can no longer be sent while a turn is streaming.

Command approval is now integrity-checked end to end: only one
command:approve request may be outstanding at a time (a concurrent request
is ignored rather than overwriting the pending command/args), a stale or
mismatched command:approval response cannot replace active approval state,
and running an approved command clears its approval state immediately (via
a ref, before React re-renders) so a double-click cannot replay
command:execute.

The `/chat` socket is now typed at a single boundary: apps/web/src/lib/
socket.ts narrows socket.io-client's untyped `io()` return value to
`ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at
creation, via the one assertion the library's types force; every consumer
(use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no
further casts. The shared contract types live in the new
apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts
shim), which re-exports them via type-only imports resolved directly
against packages/types/src (apps/web has no @mosaicstack/types package
dependency, so this stays source-only and is erased at compile time —
no package manifest or lockfile is touched). The two recorded-event test
suites now drive a shared, typed fake socket
(spa/chat/test-support/fake-chat-socket.ts) instead of an untyped
`(event: string, payload: unknown)` harness, so a wrong event name or
malformed payload fails to compile.
2026-08-09 21:39:16 -05:00
44 changed files with 5348 additions and 256 deletions
+70 -31
View File
@@ -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
| Package | Purpose | Key Dependencies | - **API:** NestJS with Fastify (`apps/gateway`)
| ------------------ | ------------------------------- | -------------------------------- | - **Web:** Next.js 16 with React 19 (`apps/web`)
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL | - **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
| `apps/web` | Next.js dashboard | React 19, Tailwind | - **Authentication:** BetterAuth (`packages/auth`)
| `packages/types` | Shared TypeScript contracts | class-validator | - **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres | - **Queue:** Valkey 8 (`packages/queue`)
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db | - **Build:** pnpm workspaces and Turborepo
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db | - **CI:** Woodpecker CI
| `packages/queue` | Valkey task queue + MCP | ioredis | - **Observability:** OpenTelemetry and Jaeger
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture Rules ### Package Map
1. Gateway is the single API surface — all clients connect through it | Package | Purpose | Key Dependencies |
2. Pi SDK is ESM-only — gateway and CLI must use ESM | ------------------ | ----------------------------- | -------------------------------- |
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts | `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
4. OTEL auto-instrumentation loads before NestJS bootstrap | `apps/web` | Next.js dashboard | React 19, Tailwind |
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db` | `packages/types` | Shared TypeScript contracts | class-validator |
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686) | `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata) | `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Structured data layer | @mosaicstack/db |
| `packages/queue` | Valkey task queue and MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture and Code Conventions
1. Gateway is the single API surface; all clients connect through it.
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not 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)
+3 -44
View File
@@ -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
+5 -1
View File
@@ -50,7 +50,11 @@ mosaic wizard # Full guided setup (gateway install → verify)
- Node.js ≥ 20 - Node.js ≥ 20
- 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
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
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';
@@ -12,6 +13,7 @@ const mockRegistry = {
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true }, { name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true }, { name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true }, { name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
], ],
skills: [], skills: [],
})), })),
@@ -72,7 +74,14 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(), broadcastSessionInfo: vi.fn(),
}; };
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService { function buildService(
redis: typeof mockRedis | null = mockRedis,
mcpClient: {
reconnectServer: ReturnType<typeof vi.fn>;
getServerStatuses: ReturnType<typeof vi.fn>;
getToolDefinitions: ReturnType<typeof vi.fn>;
} | null = null,
): CommandExecutorService {
return new CommandExecutorService( return new CommandExecutorService(
mockRegistry as never, mockRegistry as never,
mockAgentService as never, mockAgentService as never,
@@ -82,7 +91,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut
mockBrain as never, mockBrain as never,
null, null,
mockChatGateway as never, mockChatGateway as never,
null, mcpClient as never,
); );
} }
@@ -258,4 +267,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.command).toBe('tools'); expect(result.command).toBe('tools');
expect(result.message).toContain('tools'); expect(result.message).toContain('tools');
}); });
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
// Redis `set` inside /provider login is the only reachable path into the
// top-level catch in `execute()`. The raw exception must be logged
// server-side but never handed back to the socket client.
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
const rawError = new Error(distinctiveRawFailure);
const failingRedis = {
set: vi.fn().mockRejectedValue(rawError),
get: vi.fn(),
del: vi.fn(),
};
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await failingService.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('provider');
expect(result.message).toBe('Command failed due to an internal error.');
expect(result.message).not.toContain(distinctiveRawFailure);
expect(result.message).not.toContain('ECONNREFUSED');
// The real exception is still logged server-side, as the raw Error
// object itself (not stringified/interpolated into the log message).
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
// Inner catch sanitization (P3-5 operator ruling): every catch in
// command-executor.service.ts that returns a SlashCommandResultPayload
// must sanitize the client-facing message the same way the top-level
// catch does, while still logging the raw exception server-side.
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-agent-create-failure-token-A17f');
mockBrain.agents.create.mockRejectedValueOnce(marker);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'agent',
args: 'new my-new-agent',
conversationId,
};
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('agent');
expect(result.message).toBe('Failed to create agent due to an internal error.');
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'agent',
args: 'some-other-agent',
conversationId,
};
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('agent');
expect(result.message).toBe('Failed to switch agent due to an internal error.');
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
const mockMcpClient = {
reconnectServer: vi.fn().mockRejectedValue(marker),
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
};
const mcpService = buildService(mockRedis, mockMcpClient);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'mcp',
args: 'reconnect my-server',
conversationId,
};
const result = await mcpService.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('mcp');
expect(result.message).toBe(
'Failed to reconnect MCP server "my-server" due to an internal error.',
);
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
}); });
@@ -159,8 +159,13 @@ export class CommandExecutorService {
}; };
} }
} catch (err) { } catch (err) {
this.logger.error(`Command /${command} failed: ${err}`); this.logger.error(`Command /${command} failed`, err);
return { command, conversationId, success: false, message: String(err) }; return {
command,
conversationId,
success: false,
message: 'Command failed due to an internal error.',
};
} }
} }
@@ -336,11 +341,11 @@ export class CommandExecutorService {
data: { agentId: newAgent.id, agentName: newAgent.name }, data: { agentId: newAgent.id, agentName: newAgent.name },
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to create agent: ${err}`); this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
return { return {
command: 'agent', command: 'agent',
success: false, success: false,
message: `Failed to create agent: ${String(err)}`, message: 'Failed to create agent due to an internal error.',
conversationId, conversationId,
}; };
} }
@@ -391,11 +396,11 @@ export class CommandExecutorService {
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model }, data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to switch agent "${agentName}": ${err}`); this.logger.error(`Failed to switch agent "${agentName}"`, err);
return { return {
command: 'agent', command: 'agent',
success: false, success: false,
message: `Failed to switch agent: ${String(err)}`, message: 'Failed to switch agent due to an internal error.',
conversationId, conversationId,
}; };
} }
@@ -608,11 +613,12 @@ export class CommandExecutorService {
message: `MCP server "${serverName}" reconnected successfully.`, message: `MCP server "${serverName}" reconnected successfully.`,
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
return { return {
command: 'mcp', command: 'mcp',
conversationId, conversationId,
success: false, success: false,
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`, message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
}; };
} }
} }
@@ -0,0 +1,44 @@
import { Logger } from '@nestjs/common';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { McpClientService } from './mcp-client.service.js';
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
describe('McpClientService — failed connect error sanitization', () => {
const originalMcpServers = process.env['MCP_SERVERS'];
beforeEach(() => {
process.env['MCP_SERVERS'] = JSON.stringify([
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
]);
});
afterEach(() => {
vi.restoreAllMocks();
if (originalMcpServers === undefined) {
delete process.env['MCP_SERVERS'];
} else {
process.env['MCP_SERVERS'] = originalMcpServers;
}
});
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const service = new McpClientService();
await service.onModuleInit();
const statuses = service.getServerStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0]?.connected).toBe(false);
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
);
expect(loggedRawMarker).toBe(true);
});
});
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
); );
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
serverEntry.error = message; serverEntry.error = 'Connection failed (see server logs).';
serverEntry.connected = false; serverEntry.connected = false;
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`); this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
} }
@@ -1,5 +1,8 @@
import { Logger } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
import { ReloadService } from './reload.service.js'; import { ReloadService } from './reload.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
function createMockCommandRegistry() { function createMockCommandRegistry() {
return { return {
@@ -104,3 +107,79 @@ describe('ReloadService', () => {
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow(); expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
}); });
}); });
describe('ReloadService — /reload command sanitizes plugin errors', () => {
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
const registry = {
getManifest: vi.fn().mockReturnValue({
version: 1,
commands: [
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
],
skills: [],
}),
};
const reloadService = new ReloadService(registry as never);
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
reloadService.registerPlugin('unload-fails', {
pluginName: 'unload-fails',
onLoad: vi.fn().mockResolvedValue(undefined),
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
});
reloadService.registerPlugin('load-fails', {
pluginName: 'load-fails',
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
onUnload: vi.fn().mockResolvedValue(undefined),
});
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const broadcastReload = vi.fn();
const mockChatGateway = { broadcastReload };
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
const mockSessionGC = { sweepOrphans: vi.fn() };
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
const executor = new CommandExecutorService(
registry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
null,
mockBrain as never,
reloadService,
mockChatGateway as never,
null,
);
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
expect(result.success).toBe(true);
expect(result.message).toContain('unload-fails: unload failed (internal error)');
expect(result.message).toContain('load-fails: load failed (internal error)');
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
expect(broadcastReload).toHaveBeenCalledOnce();
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
);
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
);
expect(loggedUnloadMarker).toBe(true);
expect(loggedLoadMarker).toBe(true);
errorSpy.mockRestore();
});
});
+4 -2
View File
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
await plugin.onUnload(); await plugin.onUnload();
reloaded.push(name); reloaded.push(name);
} catch (err) { } catch (err) {
errors.push(`${name}: unload failed — ${err}`); this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
errors.push(`${name}: unload failed (internal error)`);
} }
} }
} }
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
try { try {
await plugin.onLoad(); await plugin.onLoad();
} catch (err) { } catch (err) {
errors.push(`${name}: load failed ${err}`); this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
errors.push(`${name}: load failed (internal error)`);
} }
} }
} }
+1
View File
@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/design-tokens": "workspace:^", "@mosaicstack/design-tokens": "workspace:^",
"@mosaicstack/types": "workspace:^",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"next": "^16.0.0", "next": "^16.0.0",
+61
View File
@@ -0,0 +1,61 @@
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
// the public `@mosaicstack/types` package. `import type` is erased at compile
// time, so this introduces no runtime dependency — it only reuses the exact
// payload shapes instead of redeclaring them.
import type { Socket } from 'socket.io-client';
import type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
} from '@mosaicstack/types';
export type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
};
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
+63 -17
View File
@@ -10,30 +10,52 @@ vi.mock('socket.io-client', () => ({
import { destroySocket, getSocket } from './socket'; import { destroySocket, getSocket } from './socket';
interface MockChatSocket {
on: ReturnType<typeof vi.fn>;
offAny: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
/** Test-only helper: fires every handler registered for `event` via
* `.on`, mirroring how a real socket.io-client instance invokes its own
* listeners (e.g. calling the registered `disconnect` handler(s) on a
* real transient disconnect). */
trigger(event: string): void;
}
function createMockSocket(): MockChatSocket {
const handlers = new Map<string, Set<() => void>>();
const mockSocket: MockChatSocket = {
on: vi.fn((event: string, handler: () => void) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
return mockSocket;
}),
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
trigger(event: string): void {
for (const handler of handlers.get(event) ?? []) handler();
},
};
return mockSocket;
}
let currentMock!: MockChatSocket;
describe('chat socket', () => { describe('chat socket', () => {
let disconnectHandler: (() => void) | undefined;
beforeEach(() => { beforeEach(() => {
disconnectHandler = undefined;
ioMock.mockReset(); ioMock.mockReset();
// A fresh object per io() call so identity assertions (same singleton vs.
const mockSocket = { // a genuinely new instance) are meaningful.
on: vi.fn((event: string, handler: () => void) => { ioMock.mockImplementation(() => {
if (event === 'disconnect') disconnectHandler = handler; currentMock = createMockSocket();
return mockSocket; return currentMock;
}), });
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
};
ioMock.mockReturnValue(mockSocket);
}); });
afterEach(() => { afterEach(() => {
destroySocket(); destroySocket();
}); });
it('creates one same-origin /chat namespace socket until it disconnects', () => { it('creates one same-origin /chat namespace socket', () => {
const first = getSocket(); const first = getSocket();
const second = getSocket(); const second = getSocket();
@@ -44,9 +66,33 @@ describe('chat socket', () => {
autoConnect: false, autoConnect: false,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
}); });
});
disconnectHandler?.(); it('keeps the same singleton instance across a transient disconnect', () => {
getSocket(); const first = getSocket();
// socket.ts must not react to a real socket's `disconnect` event by
// nulling the singleton — it registers no such handler at all now.
// Actually fire every handler registered via `.on('disconnect', ...)`
// (mirroring a real socket.io-client reconnect) instead of merely
// calling getSocket() again: this is what makes the test fail if
// production reintroduces `socket.on('disconnect', () => { socket =
// null; })`, since that handler would run here and null the singleton
// before the next getSocket() call.
currentMock.trigger('disconnect');
const second = getSocket();
expect(second).toBe(first);
expect(ioMock).toHaveBeenCalledOnce();
});
it('only creates a new singleton after an explicit destroySocket()', () => {
const first = getSocket();
destroySocket();
const second = getSocket();
expect(second).not.toBe(first);
expect(ioMock).toHaveBeenCalledTimes(2); expect(ioMock).toHaveBeenCalledTimes(2);
}); });
}); });
+16 -10
View File
@@ -1,21 +1,27 @@
import { io, type Socket } from 'socket.io-client'; import { io } from 'socket.io-client';
import type { ChatSocket } from './chat-contract';
let socket: Socket | null = null; let socket: ChatSocket | null = null;
export function getSocket(): Socket { export function getSocket(): ChatSocket {
if (!socket) { if (!socket) {
// socket.io-client 4.8.3's `io()` factory declaration always returns the
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
// generics), so this one cast is the unavoidable boundary between that and the
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
// with no further assertions.
socket = io('/chat', { socket = io('/chat', {
withCredentials: true, withCredentials: true,
autoConnect: false, autoConnect: false,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
}); }) as unknown as ChatSocket;
// Reset singleton reference when socket is fully closed so the next // A transient `disconnect` (network blip, server restart) must NOT null
// getSocket() call creates a fresh instance instead of returning a // the singleton: socket.io-client auto-reconnects this same instance,
// closed/dead socket. // and its listeners stay registered across that reconnect. Nulling here
socket.on('disconnect', () => { // previously orphaned those listeners on the next getSocket() call by
socket = null; // handing back a brand-new, unconnected instance. Only destroySocket()
}); // (an explicit, intentional teardown) may reset the singleton.
} }
return socket; return socket;
} }
+3 -1
View File
@@ -3,6 +3,8 @@ import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-r
import { LoginPage } from '@/spa/pages/login'; import { LoginPage } from '@/spa/pages/login';
import { RegisterPage } from '@/spa/pages/register'; 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 { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
import { AuthGuard, GuestGuard } from '@/spa/guards'; import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder'; import { Placeholder } from '@/spa/placeholder';
@@ -34,7 +36,7 @@ export const routes: RouteObject[] = [
element: <AuthGuard />, element: <AuthGuard />,
children: [ children: [
{ path: '/', element: <Navigate to="/chat" replace /> }, { path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <Placeholder title="Chat" /> }, { path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
{ path: '/projects', element: <Placeholder title="Projects" /> }, { path: '/projects', element: <Placeholder title="Projects" /> },
{ path: '/projects/:id', element: <Placeholder title="Project" /> }, { path: '/projects/:id', element: <Placeholder title="Project" /> },
{ path: '/tasks', element: <Placeholder title="Tasks" /> }, { path: '/tasks', element: <Placeholder title="Tasks" /> },
@@ -0,0 +1,280 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CommandsPanel } from './commands-panel';
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let root: Root | null;
let container: HTMLElement | null;
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
container = null;
});
describe('CommandsPanel', () => {
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
// Free-text server message claims a different, less alarming target
// than what will actually be sent — the UI must not rely on this.
message: 'This will only affect the staging environment.',
}}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
// The exact frozen combined action is visible...
expect(container?.textContent).toContain('/deploy');
expect(container?.textContent).toContain('prod');
// ...and the misleading server free-text is never shown next to it.
expect(container?.textContent).not.toContain('staging environment');
});
it('does not throw when a manifest commands entry is null', async () => {
const manifest = {
commands: [
null,
{
name: 'model',
aliases: [],
description: 'Change the active model',
scope: 'core',
execution: 'socket',
available: true,
},
],
skills: [null],
version: 1,
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
await expect(
render(
<CommandsPanel
manifest={manifest}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
),
).resolves.not.toThrow();
expect(container?.textContent).toContain('model');
});
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
}}
pendingApproval={{ command: 'deploy' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent?.toLowerCase()).toContain('no args');
});
it('renders skills from a skills-only manifest', async () => {
await render(
<CommandsPanel
manifest={{
commands: [],
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
version: 1,
}}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('brave-search');
expect(container?.textContent).toContain('Search the web');
});
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
const approval = {
conversationId: 'c1',
command: 'deploy',
success: { truthy: 'object' },
approvalId: { also: 'object' },
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={approval}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
button.textContent?.includes('Run approved command'),
),
).toBe(false);
});
it('shows the guarded server-provided denial reason for a denied approval', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy',
success: false,
message: 'Not authorized',
}}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Not authorized');
});
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Denied.');
});
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
await render(
<CommandsPanel
manifest={null}
results={[
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
{ conversationId: 'c1', command: 'deploy', success: false },
]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Unknown model');
expect(container?.textContent).toContain('Command failed.');
});
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
const hostileMessage = 'y'.repeat(50_000);
await render(
<CommandsPanel
manifest={null}
results={[
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
const text = container?.textContent ?? '';
expect(text.length).toBeLessThan(hostileMessage.length);
});
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
const manifest = {
commands: 'not-an-array',
skills: null,
version: 1,
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
await expect(
render(
<CommandsPanel
manifest={manifest}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
),
).resolves.not.toThrow();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useState, type ReactElement } from 'react';
import type { PendingApproval } from './use-chat-connection';
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
import { asNonEmptyString, asString } from './runtime-guards';
import type {
CommandManifest,
SlashCommandApprovalResultPayload,
SlashCommandResultPayload,
} from '@/lib/chat-contract';
/** Stable fallback copy shown for a failed command only when the server's
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
* malformed — the structured contract reason itself is otherwise shown
* directly, never a raw thrown exception, stack trace, or object value. */
const COMMAND_FAILURE_COPY = 'Command failed.';
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
* stored command:result message at ingestion, but this component must never
* assume every caller went through that path — bounding again here means a
* hostile/oversized message can never force an unbounded render. */
function boundMessage(value: string): string {
return value.length > MAX_COMMAND_MESSAGE_CHARS
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
: value;
}
interface CommandsPanelProps {
manifest: CommandManifest | null;
results: SlashCommandResultPayload[];
approval: SlashCommandApprovalResultPayload | null;
pendingApproval: PendingApproval | null;
hasConversation: boolean;
onExecute: (input: { command: string; args?: string }) => void;
onApprove: (input: { command: string; args?: string }) => void;
onRunApproved: () => void;
}
export function CommandsPanel({
manifest,
results,
approval,
pendingApproval,
hasConversation,
onExecute,
onApprove,
onRunApproved,
}: CommandsPanelProps): ReactElement {
const [command, setCommand] = useState('');
const [args, setArgs] = useState('');
// Defense-in-depth: the reducer already normalizes success/approvalId
// before storing `approval`, but a matching command string alone must
// never be trusted here either — require the literal boolean `true` and a
// non-empty string approvalId, not merely truthy values.
const canRunApproved =
approval?.success === true &&
typeof approval.approvalId === 'string' &&
approval.approvalId.length > 0 &&
!!pendingApproval &&
pendingApproval.command === approval.command;
// A manifest arrives from the server as untyped JSON at runtime — guard
// both collections before mapping so a malformed manifest cannot throw.
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
return (
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
{commands.length > 0 ? (
<ul aria-label="Available commands" className="flex flex-col gap-1">
{commands.map((cmd, index) => (
<li key={asString(cmd?.name) || `cmd-${index}`}>
<strong>/{asString(cmd?.name)}</strong> {asString(cmd?.description)}
</li>
))}
</ul>
) : null}
{skills.length > 0 ? (
<ul aria-label="Available skills" className="flex flex-col gap-1">
{skills.map((skill, index) => (
<li key={asString(skill?.name) || `skill-${index}`}>
<strong>/skill:{asString(skill?.name)}</strong> {asString(skill?.description)}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap items-center gap-2">
<input
aria-label="Command name"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="command"
/>
<input
aria-label="Command arguments"
value={args}
onChange={(event) => setArgs(event.target.value)}
placeholder="args (optional)"
/>
<button
type="button"
disabled={!hasConversation || !command.trim()}
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
>
Run command
</button>
<button
type="button"
disabled={!hasConversation || !command.trim()}
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
>
Request approval
</button>
</div>
{approval ? (
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
{/* A successful approval shows stable client copy only — never
the server-controlled approval.message or echoed
approval.command as the primary confirmation. The frozen local
pendingApproval below (not this line) is the sole authoritative
statement of what will run. A denial, by contrast, is not an
execution authority and safely surfaces the guarded structured
reason the server gave (e.g. "Not authorized"), falling back to
a stable copy only when absent/malformed. */}
<span>
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
</span>
{canRunApproved && pendingApproval ? (
<>
{/* Authoritative frozen local command+args — what the click below
will actually emit. The server's `approval` above is display-only
and must never be trusted to represent the executed payload. */}
<span>
Will run: /{pendingApproval.command}{' '}
{pendingApproval.args ? pendingApproval.args : '(no args)'}
</span>
<button type="button" onClick={onRunApproved}>
Run approved command
</button>
</>
) : null}
</div>
) : null}
{results.length > 0 ? (
<ul aria-label="Command results" className="flex flex-col gap-1">
{results.map((result, index) => (
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
{result.success
? typeof result.message === 'string' && result.message
? `${boundMessage(result.message)}`
: ''
: `${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
</li>
))}
</ul>
) : null}
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, type KeyboardEvent, type ReactElement } from 'react';
interface ComposerProps {
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
* throughout streaming — a superset of `streaming` that also covers the
* pre-ack window where a second send could otherwise slip through. */
sending: boolean;
hasConversation: boolean;
}
export function Composer({
onSend,
onStop,
streaming,
sending,
hasConversation,
}: ComposerProps): ReactElement {
const [content, setContent] = useState('');
const [provider, setProvider] = useState('');
const [modelId, setModelId] = useState('');
const busy = streaming || sending;
function submit(): void {
if (busy) return;
const trimmed = content.trim();
if (!trimmed) return;
onSend({
content: trimmed,
provider: provider.trim() || undefined,
modelId: modelId.trim() || undefined,
});
setContent('');
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
className="flex flex-col gap-2 border-t p-4"
>
<div className="flex flex-wrap gap-2">
<input
aria-label="Provider"
value={provider}
onChange={(event) => setProvider(event.target.value)}
placeholder="Provider (optional)"
className="rounded border px-2 py-1 text-xs"
/>
<input
aria-label="Model"
value={modelId}
onChange={(event) => setModelId(event.target.value)}
placeholder="Model (optional)"
className="rounded border px-2 py-1 text-xs"
/>
</div>
<div className="flex items-end gap-2">
<textarea
aria-label="Message"
value={content}
onChange={(event) => setContent(event.target.value)}
onKeyDown={handleKeyDown}
rows={2}
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={!content.trim() || busy}
className="rounded px-3 py-2 text-sm font-medium"
>
Send
</button>
<button
type="button"
aria-label="Stop"
disabled={!hasConversation || !streaming}
onClick={onStop}
className="rounded px-3 py-2 text-sm font-medium"
>
Stop
</button>
</div>
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
* flood any of these collections; caps keep memory/render cost flat instead
* of growing unboundedly for the lifetime of the connection.
*/
/** Max characters retained for the in-flight streamed text/thinking buffers. */
export const MAX_STREAM_CHARS = 20_000;
/** Max transcript turns retained (oldest dropped first). */
export const MAX_MESSAGES = 500;
/** Max tool-call entries (including anomaly entries) retained per turn history. */
export const MAX_TOOLS = 200;
/** Max slash-command results retained. */
export const MAX_COMMAND_RESULTS = 200;
/** Max commands/skills accepted from a single manifest push. */
export const MAX_MANIFEST_ITEMS = 500;
/** Max executed approval IDs remembered for single-flight dedup. */
export const MAX_EXECUTED_APPROVAL_IDS = 200;
/** Max characters retained for a single command:result message — a hostile
* or malfunctioning gateway must not be able to push an unbounded curated
* success/failure reason into state (or, defensively, onto the page). */
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
@@ -0,0 +1,39 @@
import type { ReactElement } from 'react';
import type { ChatTranscriptMessage } from './use-chat-connection';
interface MessageTranscriptProps {
messages: ChatTranscriptMessage[];
streaming: boolean;
text: string;
}
export function MessageTranscript({
messages,
streaming,
text,
}: MessageTranscriptProps): ReactElement {
return (
<div
role="log"
aria-live="polite"
aria-label="Conversation"
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
>
{messages.map((message) => (
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
<span>{message.text}</span>
{message.thinking ? (
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
) : null}
</div>
))}
{streaming ? (
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
<span className="font-medium">Assistant: </span>
<span>{text || 'Thinking…'}</span>
</div>
) : null}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Socket.IO payloads are only statically typed at the call site — a
* misbehaving or compromised gateway can send anything at runtime. These
* guards protect the dereference sites that would otherwise throw (`.map` on
* a non-array, `.toFixed` on a non-number) or render an object as a React
* child.
*/
export function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
/** Like `asString`, but an empty string also falls back — used for guarded
* contract-provided reason strings (e.g. a denial or failure message) where
* an empty string is not a meaningful value to display in place of the
* stable fallback copy. */
export function asNonEmptyString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
export function asFiniteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
* fallback — callers that must not fabricate a plausible-looking value (e.g.
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
* honest "unavailable" label instead. */
export function asFiniteNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function asStringArray(value: unknown): string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/** 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
* string, nothing else. Every site that establishes or compares
* `state.conversationId` against a raw socket payload must route through
* this guard so a malformed first frame (null/object/number/empty string)
* can never be adopted verbatim. */
export function asConversationId(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
+68
View File
@@ -0,0 +1,68 @@
import type { ReactElement } from 'react';
import type { SessionInfoPayload } from '@/lib/chat-contract';
import { MAX_MANIFEST_ITEMS } from './limits';
import { asString, asStringArray } from './runtime-guards';
interface SessionPanelProps {
sessionInfo: SessionInfoPayload | null;
onSetThinking: (level: string) => void;
}
const THINKING_LEVEL_UNAVAILABLE = '';
export function SessionPanel({
sessionInfo,
onSetThinking,
}: SessionPanelProps): ReactElement | null {
if (!sessionInfo) return null;
// The reducer already caps this before storing it, but the render site
// defends independently — a hostile payload must never be able to force
// this <select> to lay out an unbounded number of options.
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
0,
MAX_MANIFEST_ITEMS,
);
const hasThinkingLevels = availableThinkingLevels.length > 0;
return (
<section
aria-label="Session info"
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
>
<span>{asString(sessionInfo.provider, 'unknown')}</span>
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
<label className="flex items-center gap-2">
<span>Thinking level</span>
<select
aria-label="Thinking level"
value={
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
}
onChange={(event) => {
// The placeholder option is not a real, settable level — a
// malformed availableThinkingLevels list must never let the
// client emit set:thinking for it.
if (!hasThinkingLevels) return;
onSetThinking(event.target.value);
}}
>
{hasThinkingLevels ? (
availableThinkingLevels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))
) : (
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
)}
</select>
</label>
{sessionInfo.routingDecision ? (
<span title={asString(sessionInfo.routingDecision.ruleName)}>
{asString(sessionInfo.routingDecision.reason)}
</span>
) : null}
</section>
);
}
@@ -0,0 +1,125 @@
import { vi } from 'vitest';
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
type ServerEvent = keyof ServerToClientEvents;
type ClientEvent = keyof ClientToServerEvents;
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
event: K;
payload: ClientPayload<K>;
}
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
export interface FakeChatSocket {
connected: boolean;
connect(): FakeChatSocket;
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
}
/**
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
* `(event: string, payload: unknown) => void` mock, every public method here is
* checked against the real `/chat` contract — a typo'd event name or a payload
* missing a required field fails to compile instead of silently no-op'ing at
* runtime.
*/
/** Socket.IO's built-in connection-state events. Not part of the app-level
* ServerToClientEvents contract, but real sockets always support them and
* `useChatConnection` registers a `disconnect` handler on the real socket. */
type LifecycleEvent = 'connect' | 'disconnect';
export function createFakeChatSocket(): {
socket: FakeChatSocket;
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
emitted: EmittedEvent[];
serverEmit<K extends ServerEvent>(
event: K,
payload: Parameters<ServerToClientEvents[K]>[0],
): void;
/** Escape hatch for malformed-payload tests: bypasses the compile-time
* payload contract to simulate a genuinely untrusted runtime value from the
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
serverEmitRaw(event: ServerEvent, payload: unknown): void;
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
* registered via `socket.on('disconnect', ...)` without clearing any
* listeners, mirroring how a real reconnecting socket behaves. */
simulateDisconnect(): void;
/** Simulates socket.io-client's automatic reconnect of the *same*
* instance after a transient disconnect: marks the socket connected again
* and fires any handler(s) registered via `socket.on('connect', ...)`,
* without clearing or replacing any listeners. */
simulateReconnect(): void;
} {
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
const emitted: EmittedEvent[] = [];
// Internal storage is intentionally keyed loosely (the per-event handler shape
// varies by K, which a single Map can't express); the generic signatures on the
// exported `socket`/`serverEmit` above and below are what keep test call sites
// type-checked against ServerToClientEvents/ClientToServerEvents.
const socket = {
connected: false,
connect: vi.fn(function connect(this: void) {
socket.connected = true;
return socket;
}),
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)?.add(handler);
return socket;
}),
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
listeners.get(event)?.delete(handler);
return socket;
}),
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
emitted.push({ event, payload } as EmittedEvent);
return socket;
}),
} as unknown as FakeChatSocket;
function serverEmit<K extends ServerEvent>(
event: K,
payload: Parameters<ServerToClientEvents[K]>[0],
): void {
for (const handler of listeners.get(event) ?? []) {
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
}
}
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
for (const handler of listeners.get(event) ?? []) {
(handler as (payload: unknown) => void)(payload);
}
}
function simulateDisconnect(): void {
socket.connected = false;
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
for (const handler of listeners.get(lifecycleKey) ?? []) {
(handler as () => void)();
}
}
function simulateReconnect(): void {
socket.connected = true;
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
for (const handler of listeners.get(lifecycleKey) ?? []) {
(handler as () => void)();
}
}
return {
socket,
listeners,
emitted,
serverEmit,
serverEmitRaw,
simulateDisconnect,
simulateReconnect,
};
}
@@ -0,0 +1,63 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ToolCallList } from './tool-call-list';
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let root: Root | null;
let container: HTMLElement | null;
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
container = null;
});
describe('ToolCallList', () => {
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
await render(
<ToolCallList
tools={[
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
]}
/>,
);
const items = [...(container?.querySelectorAll('li') ?? [])];
expect(items).toHaveLength(2);
expect(items[0]?.textContent).toContain('success');
expect(items[1]?.textContent).toContain('running');
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
);
expect(duplicateKeyWarning).toBe(false);
consoleError.mockRestore();
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { ReactElement } from 'react';
import type { ToolCallState } from './use-chat-connection';
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
if (tools.length === 0) return null;
return (
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
{tools.map((tool, index) => (
<li
// A valid server-controlled toolCallId can legitimately repeat
// (e.g. two tool:start events sharing one id) — keying on it alone
// would give React two identical keys. Pairing it with its
// (stable, append-only) render index keeps every key unique.
key={`${tool.toolCallId}-${index}`}
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
>
{tool.toolName} {' '}
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
</li>
))}
</ul>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
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';
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
function Boom(): never {
throw new Error('render blew up');
}
/** Recursively clones the real exported route table, replacing only the
* `/chat` route's `element` with `<Boom />` — every other route (including
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
* exactly as exported. This is what makes the test fail if a future change
* removes the real route's `errorElement`, unlike a hand-built independent
* route tree that could drift from production undetected. */
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
return nodes.map((node) => {
const cloned: RouteObject = { ...node };
if (cloned.path === '/chat') {
cloned.element = <Boom />;
}
if (cloned.children) {
cloned.children = replaceChatElementWithBoom(cloned.children);
}
return cloned;
});
}
let root: Root | null;
let container: HTMLElement;
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
useSessionMock.mockReset();
});
describe('ChatRouteErrorBoundary', () => {
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
const routeObjects = replaceChatElementWithBoom(routes);
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
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,22 @@
import type { ReactElement } from 'react';
import { useRouteError } from 'react-router-dom';
/**
* `/chat` renders live, server-driven state (streamed text, tool calls,
* manifests) that can carry malformed payloads no compile-time contract can
* fully rule out at every dereference site. This is the last line of
* defense: if something still throws during render, show a recoverable
* alert instead of leaving the user on a blank/white screen.
*/
export function ChatRouteErrorBoundary(): 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">Something went wrong loading chat.</p>
<a href="/chat" className="text-sm underline">
Reload chat
</a>
</div>
);
}
+636
View File
@@ -0,0 +1,636 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
getSocketMock: vi.fn(),
destroySocketMock: vi.fn(),
}));
vi.mock('@/lib/socket', () => ({
getSocket: getSocketMock,
destroySocket: destroySocketMock,
}));
import { ChatPage } from './chat';
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
const proto =
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
setter?.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
function selectValue(el: HTMLSelectElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
setter?.call(el, value);
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) throw new Error(`Button with text "${text}" not found`);
return button;
}
let fake: ReturnType<typeof createFakeChatSocket>;
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(async () => {
fake = createFakeChatSocket();
getSocketMock.mockReset().mockReturnValue(fake.socket);
destroySocketMock.mockReset();
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ChatPage />);
});
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
});
describe('ChatPage', () => {
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
fake.serverEmit('agent:tool:start', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'web_search',
});
});
expect(container.textContent).toContain('pondering…');
expect(container.textContent).toContain('Hello!');
expect(container.textContent).toContain('web_search');
expect(container.textContent).toMatch(/running/i);
await act(async () => {
fake.serverEmit('agent:tool:end', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'web_search',
isError: false,
});
fake.serverEmit('agent:end', {
conversationId: 'c1',
usage: {
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
cost: 0.02,
context: { percent: 3, window: 200000 },
},
});
});
expect(container.textContent).toMatch(/success/i);
expect(container.textContent).toContain('Hello!');
expect(container.textContent).toMatch(/46/);
});
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('commands:manifest', {
manifest: {
commands: [
{
name: 'model',
aliases: ['m'],
description: 'Change the active model',
scope: 'core',
execution: 'socket',
available: true,
},
],
skills: [],
version: 1,
},
});
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
availableThinkingLevels: ['low', 'medium', 'high'],
routingDecision: {
model: 'claude',
provider: 'anthropic',
ruleName: 'default',
reason: 'default routing',
},
});
});
expect(container.textContent).toContain('model');
expect(container.textContent).toContain('Change the active model');
expect(container.textContent).toContain('anthropic');
expect(container.textContent).toContain('default routing');
const select = container.querySelector(
'select[aria-label="Thinking level"]',
) as HTMLSelectElement;
expect(select).toBeTruthy();
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
await act(async () => {
selectValue(select, 'high');
});
expect(fake.emitted).toContainEqual({
event: 'set:thinking',
payload: { conversationId: 'c1', level: 'high' },
});
});
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const commandInput = container.querySelector(
'input[aria-label="Command name"]',
) as HTMLInputElement;
const argsInput = container.querySelector(
'input[aria-label="Command arguments"]',
) as HTMLInputElement;
await act(async () => {
setValue(commandInput, 'model');
setValue(argsInput, 'gpt-5');
});
await act(async () => {
findButton(container, 'Run command').click();
});
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
});
await act(async () => {
setValue(commandInput, 'deploy');
setValue(argsInput, 'prod');
});
await act(async () => {
findButton(container, 'Request approval').click();
});
expect(fake.emitted).toContainEqual({
event: 'command:approve',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
expect(container.textContent).toMatch(/approved/i);
await act(async () => {
findButton(container, 'Run approved command').click();
});
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
});
});
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
fake.serverEmit('command:result', {
conversationId: 'c1',
command: 'model',
success: false,
message: 'Unknown model',
});
});
const alerts = [...container.querySelectorAll('[role="alert"]')];
const alertText = alerts.map((node) => node.textContent).join(' ');
expect(alertText).toContain('The model is unavailable');
// The structured, contract-provided denial reason is visibly rendered.
expect(alertText).toContain('Unknown model');
});
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmitRaw('command:result', {
conversationId: 'c1',
command: 'model',
success: false,
message: { bad: 'object' },
});
});
const alerts = [...container.querySelectorAll('[role="alert"]')];
const alertText = alerts.map((node) => node.textContent).join(' ');
expect(alertText).toContain('Command failed.');
});
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'level-0',
availableThinkingLevels: hostileLevels,
});
});
const select = container.querySelector(
'select[aria-label="Thinking level"]',
) as HTMLSelectElement;
expect(select).toBeTruthy();
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
});
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmitRaw('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
availableThinkingLevels: null,
});
});
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
const select = container.querySelector(
'select[aria-label="Thinking level"]',
) as HTMLSelectElement;
expect(select).toBeTruthy();
// A malformed level list still shows a visible, safe placeholder option
// rather than a silently empty select.
expect([...select.options]).toHaveLength(1);
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
await act(async () => {
selectValue(select, '');
});
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
});
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmitRaw('agent:end', {
conversationId: 'c1',
usage: {
provider: { nested: 'object' },
modelId: undefined,
thinkingLevel: 'medium',
tokens: { total: 'not-a-number' },
cost: undefined,
context: { percent: null, window: 200000 },
},
});
});
const usage = container.querySelector('[aria-label="Usage"]');
expect(usage).toBeTruthy();
expect(usage?.textContent).toContain('tokens unavailable');
expect(usage?.textContent).toContain('cost unavailable');
expect(usage?.textContent).not.toContain('0 tokens');
expect(usage?.textContent).not.toContain('$0.0000');
expect(usage?.textContent).toContain('unknown/unknown');
});
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
}),
).resolves.not.toThrow();
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
node.textContent?.includes('Message accepted'),
);
expect(status).toBeTruthy();
// A malformed messageId gets a stable, visible fallback — never blank,
// never the raw object.
expect(status?.textContent).toContain('unknown');
});
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('system:reload', {
commands: [],
skills: [],
providers: [],
message: { bad: 'object' },
});
}),
).resolves.not.toThrow();
const status = container.querySelector('[role="status"]');
expect(status).toBeTruthy();
// A malformed reload message renders a stable, visible fallback rather
// than a silently empty status line.
expect(status?.textContent).toContain('Commands reloaded.');
});
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
await expect(
act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
}),
).resolves.not.toThrow();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
});
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) 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(
'button[aria-label="Stop"]',
) as HTMLButtonElement;
expect(stopButtonBefore.disabled).toBe(true);
await act(async () => {
setValue(textarea, 'hello there');
setValue(providerInput, 'anthropic');
setValue(modelInput, 'claude');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'hello there',
provider: 'anthropic',
modelId: 'claude',
},
});
expect(container.textContent).toContain('hello there');
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
const stopButtonDuring = container.querySelector(
'button[aria-label="Stop"]',
) as HTMLButtonElement;
expect(stopButtonDuring.disabled).toBe(false);
await act(async () => {
stopButtonDuring.click();
});
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
});
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'hello');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
await act(async () => {
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
availableThinkingLevels: ['low', 'medium', 'high'],
});
});
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
expect(container.textContent).toContain('anthropic');
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
expect(container.textContent).toContain('anthropic');
});
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'hello');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
await act(async () => {
fake.serverEmit('error', {
conversationId: 'c1',
error: 'Failed to start agent session. Please try again.',
});
});
const alerts = [...container.querySelectorAll('[role="alert"]')];
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
true,
);
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
expect(stopButton.disabled).toBe(true);
});
it('shows an accessible status once the message is acknowledged', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const statuses = [...container.querySelectorAll('[role="status"]')];
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
});
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
});
expect(container.textContent).toContain('reasoning about it');
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(container.textContent).toContain('reasoning about it');
expect(container.textContent).toContain('Done.');
});
it('ignores a concurrent approval request and only executes the approved command once', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const commandInput = container.querySelector(
'input[aria-label="Command name"]',
) as HTMLInputElement;
const argsInput = container.querySelector(
'input[aria-label="Command arguments"]',
) as HTMLInputElement;
await act(async () => {
setValue(commandInput, 'deploy');
setValue(argsInput, 'prod');
});
await act(async () => {
findButton(container, 'Request approval').click();
});
await act(async () => {
setValue(argsInput, 'staging');
});
await act(async () => {
findButton(container, 'Request approval').click();
});
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
expect(fake.emitted).toContainEqual({
event: 'command:approve',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
await act(async () => {
findButton(container, 'Run approved command').click();
findButton(container, 'Run approved command').click();
});
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
});
});
it('disables sending a second message while a turn is streaming', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'first');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
const sendButton = findButton(container, 'Send');
expect(sendButton.disabled).toBe(true);
await act(async () => {
setValue(textarea, 'second');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
});
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
expect(fake.listeners.size).toBeGreaterThan(0);
await act(async () => {
root?.unmount();
});
root = null;
for (const [, handlers] of fake.listeners) {
expect(handlers.size).toBe(0);
}
expect(destroySocketMock).toHaveBeenCalledOnce();
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { ReactElement } from 'react';
import { CommandsPanel } from '@/spa/chat/commands-panel';
import { Composer } from '@/spa/chat/composer';
import { MessageTranscript } from '@/spa/chat/message-transcript';
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
import { SessionPanel } from '@/spa/chat/session-panel';
import { ToolCallList } from '@/spa/chat/tool-call-list';
import { useChatConnection } from '@/spa/chat/use-chat-connection';
/** 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
* an unknown token count must never look the same. */
function formatTokens(value: unknown): string {
const tokens = asFiniteNumberOrNull(value);
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
}
/** Same honesty guarantee as `formatTokens`, for cost. */
function formatCost(value: unknown): string {
const cost = asFiniteNumberOrNull(value);
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
}
export function ChatPage(): ReactElement {
const { state, actions } = useChatConnection();
const hasConversation = state.conversationId !== null;
return (
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
<header className="border-b px-4 py-3">
<h1 className="text-lg font-semibold">Chat</h1>
</header>
{state.systemReload ? (
<div role="status" className="border-b px-4 py-2 text-sm">
{asString(state.systemReload.message)}
</div>
) : null}
{state.error ? (
<div role="alert" className="border-b px-4 py-2 text-sm">
{asString(state.error)}
</div>
) : null}
{state.ack ? (
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
{asString(state.ack.messageId, 'unknown')}
</div>
) : null}
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
{state.thinking ? (
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
{state.thinking}
</section>
) : null}
<ToolCallList tools={state.tools} />
{state.usage ? (
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
</div>
) : null}
<CommandsPanel
manifest={state.manifest}
results={state.commandResults}
approval={state.approval}
pendingApproval={state.pendingApproval}
hasConversation={hasConversation}
onExecute={actions.executeCommand}
onApprove={actions.approveCommand}
onRunApproved={actions.runApprovedCommand}
/>
<Composer
onSend={actions.sendMessage}
onStop={actions.abort}
streaming={state.streaming}
sending={state.sending}
hasConversation={hasConversation}
/>
</div>
);
}
+12
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { RouteObject } from 'react-router-dom'; 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';
function collectPaths(routeObjects: RouteObject[]): string[] { function collectPaths(routeObjects: RouteObject[]): string[] {
return routeObjects.flatMap((route) => [ return routeObjects.flatMap((route) => [
@@ -55,4 +56,15 @@ describe('SPA route table', () => {
expect(element.type).not.toBe(Placeholder); expect(element.type).not.toBe(Placeholder);
}, },
); );
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
const authPaths = collectPaths(routes.at(1)?.children ?? []);
expect(authPaths).toContain('/chat');
const element = findRoute(routes, '/chat')?.element;
expect(isValidElement(element)).toBe(true);
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
expect(element.type).not.toBe(Placeholder);
expect(element.type).toBe(ChatPage);
});
}); });
@@ -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.
+65
View File
@@ -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.
-115
View File
@@ -1,115 +0,0 @@
# 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 (P1P6)**; 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 (P1P6)
| # | 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.
@@ -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
;; ;;
@@ -94,8 +94,8 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
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 if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1 exit 1
fi fi
@@ -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)"
@@ -53,6 +53,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="")'
;; ;;
@@ -128,18 +129,27 @@ run_assertion() {
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'
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))
@@ -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,8 +30,8 @@ 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
+3
View File
@@ -253,6 +253,9 @@ importers:
'@mosaicstack/design-tokens': '@mosaicstack/design-tokens':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/design-tokens version: link:../../packages/design-tokens
'@mosaicstack/types':
specifier: workspace:^
version: link:../../packages/types
better-auth: better-auth:
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.5([email protected])([email protected])([email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected](@opentelemetry/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected])([email protected](@noble/[email protected]))([email protected])) version: 1.5.5([email protected])([email protected])([email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected](@opentelemetry/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected])([email protected](@noble/[email protected]))([email protected]))