chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@mosaicstack/types",
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/types"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeCapability,
|
||||
RuntimeError,
|
||||
RuntimeSession,
|
||||
RuntimeStreamEvent,
|
||||
} from './agent-runtime-provider.js';
|
||||
|
||||
describe('AgentRuntimeProvider contract', (): void => {
|
||||
it('exposes stable, normalized runtime-only contracts', (): void => {
|
||||
const capability: RuntimeCapability = 'session.attach';
|
||||
const session: RuntimeSession = {
|
||||
id: 'session-1',
|
||||
providerId: 'fleet',
|
||||
runtimeId: 'tmux',
|
||||
state: 'active',
|
||||
createdAt: '2026-07-12T00:00:00.000Z',
|
||||
updatedAt: '2026-07-12T00:00:00.000Z',
|
||||
};
|
||||
const event: RuntimeStreamEvent = {
|
||||
type: 'message.delta',
|
||||
sessionId: session.id,
|
||||
cursor: '2',
|
||||
occurredAt: session.updatedAt,
|
||||
content: 'hello',
|
||||
};
|
||||
const error: RuntimeError = {
|
||||
code: 'capability_unsupported',
|
||||
message: 'Denied',
|
||||
retryable: false,
|
||||
};
|
||||
const attach: RuntimeAttachHandle = {
|
||||
attachmentId: 'attach-1',
|
||||
sessionId: session.id,
|
||||
mode: 'read',
|
||||
expiresAt: session.updatedAt,
|
||||
};
|
||||
const provider: Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'> =
|
||||
{} as Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'>;
|
||||
expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
export type RuntimeCapability =
|
||||
| 'session.list'
|
||||
| 'session.tree'
|
||||
| 'session.stream'
|
||||
| 'session.send'
|
||||
| 'session.attach'
|
||||
| 'session.terminate';
|
||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
||||
|
||||
/** Transitional capability inventory is normalized; provider legacy vocabularies never enter core. */
|
||||
export type TransitionalRuntimeCapability = 'kanban' | 'skills' | 'memory' | 'tools' | 'cron';
|
||||
export type TransitionalCapabilityStatus = 'supported' | 'unsupported';
|
||||
export interface TransitionalCapabilityInventoryEntry {
|
||||
capability: TransitionalRuntimeCapability;
|
||||
status: TransitionalCapabilityStatus;
|
||||
}
|
||||
/** Optional extension for transitional adapters; not every runtime has Hermes inventory. */
|
||||
export interface TransitionalCapabilityInventoryProvider {
|
||||
transitionalCapabilityMatrix(
|
||||
scope: RuntimeScope,
|
||||
): Promise<TransitionalCapabilityInventoryEntry[]>;
|
||||
assertTransitionalCapability(
|
||||
capability: TransitionalRuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void>;
|
||||
}
|
||||
export type RuntimeAttachMode = 'read' | 'control';
|
||||
|
||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||
export interface RuntimeScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
export interface RuntimeSession {
|
||||
id: string;
|
||||
providerId: string;
|
||||
runtimeId: string;
|
||||
parentSessionId?: string;
|
||||
state: RuntimeSessionState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface RuntimeSessionTree {
|
||||
session: RuntimeSession;
|
||||
children: RuntimeSessionTree[];
|
||||
}
|
||||
export interface RuntimeCapabilitySet {
|
||||
supported: RuntimeCapability[];
|
||||
}
|
||||
export interface RuntimeHealth {
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
checkedAt: string;
|
||||
detail?: string;
|
||||
}
|
||||
export interface RuntimeMessage {
|
||||
content: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
export interface RuntimeAttachHandle {
|
||||
attachmentId: string;
|
||||
sessionId: string;
|
||||
mode: RuntimeAttachMode;
|
||||
expiresAt: string;
|
||||
}
|
||||
export interface RuntimeError {
|
||||
code:
|
||||
| 'capability_unsupported'
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'conflict'
|
||||
| 'unavailable'
|
||||
| 'invalid_request';
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
export type RuntimeStreamEvent =
|
||||
| {
|
||||
type: 'session.state';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
state: RuntimeSessionState;
|
||||
}
|
||||
| {
|
||||
type: 'message.delta';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'message.complete';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
messageId: string;
|
||||
}
|
||||
| {
|
||||
type: 'runtime.error';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
error: RuntimeError;
|
||||
};
|
||||
|
||||
/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */
|
||||
export interface AgentRuntimeProvider {
|
||||
readonly id: string;
|
||||
capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet>;
|
||||
health(scope: RuntimeScope): Promise<RuntimeHealth>;
|
||||
listSessions(scope: RuntimeScope): Promise<RuntimeSession[]>;
|
||||
getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]>;
|
||||
streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle>;
|
||||
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScopes,
|
||||
normalizeLeaseEpoch,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type ConnectorExecutionContext,
|
||||
type LogicalAgentIdentity,
|
||||
} from './connector-lease.dto.js';
|
||||
|
||||
describe('logical agent connector lease contract', (): void => {
|
||||
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
|
||||
const identity = normalizeLogicalAgentIdentity({
|
||||
tenantId: ' tenant-01 ',
|
||||
logicalAgentId: ' MOS.Primary ',
|
||||
});
|
||||
|
||||
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
|
||||
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
|
||||
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
|
||||
expect(Object.isFrozen(identity)).toBe(true);
|
||||
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
|
||||
});
|
||||
|
||||
it('canonicalizes scopes and decimal fencing epochs', (): void => {
|
||||
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
|
||||
'runtime.send',
|
||||
'tool.execute',
|
||||
]);
|
||||
expect(normalizeLeaseEpoch('00042')).toBe('42');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'mos'],
|
||||
['tenant', ''],
|
||||
['tenant', 'claude session/123'],
|
||||
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
|
||||
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
|
||||
});
|
||||
|
||||
it('defines an adapter context without harness-native identity fields', (): void => {
|
||||
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
|
||||
const context: ConnectorExecutionContext = {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||
leaseEpoch: '7',
|
||||
scopes: ['runtime.send'],
|
||||
correlationId: 'correlation-1',
|
||||
grantExpiresAt: '2026-07-14T18:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(context).not.toHaveProperty('sessionId');
|
||||
expect(context).not.toHaveProperty('tmuxSession');
|
||||
expect(context).not.toHaveProperty('providerSessionId');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
|
||||
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
|
||||
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
|
||||
|
||||
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
|
||||
export interface LogicalAgentIdentity {
|
||||
readonly tenantId: string;
|
||||
readonly logicalAgentId: string;
|
||||
}
|
||||
|
||||
export interface LogicalAgentBinding {
|
||||
readonly identity: LogicalAgentIdentity;
|
||||
readonly bindingId: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLease extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly acquiredAt: string;
|
||||
readonly heartbeatAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly releasedAt?: string;
|
||||
}
|
||||
|
||||
export interface AcquireConnectorLeaseInput {
|
||||
readonly identity: LogicalAgentIdentity;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
|
||||
readonly expectedEpoch: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatConnectorLeaseInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface ReleaseConnectorLeaseInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface IssueConnectorExecutionGrantInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Internal server grant. Object provenance is checked in addition to these fields. */
|
||||
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly issuedAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Normalized context passed to an adapter only after current-lease validation. */
|
||||
export interface ConnectorExecutionContext extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly correlationId: string;
|
||||
readonly grantExpiresAt: string;
|
||||
}
|
||||
|
||||
export interface FencedConnectorAdapter<TInput, TOutput> {
|
||||
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
|
||||
}
|
||||
|
||||
export type ConnectorLeaseAuditEventType =
|
||||
| 'acquire'
|
||||
| 'renew'
|
||||
| 'takeover'
|
||||
| 'reject'
|
||||
| 'release'
|
||||
| 'expiry';
|
||||
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
|
||||
export type ConnectorLeaseRejectReason =
|
||||
| 'policy_denied'
|
||||
| 'lease_held'
|
||||
| 'takeover_required'
|
||||
| 'cas_mismatch'
|
||||
| 'lease_missing'
|
||||
| 'lease_released'
|
||||
| 'lease_expired'
|
||||
| 'stale_epoch'
|
||||
| 'connector_mismatch'
|
||||
| 'scope_denied'
|
||||
| 'forged_grant'
|
||||
| 'grant_expired';
|
||||
|
||||
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
|
||||
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
|
||||
readonly event: ConnectorLeaseAuditEventType;
|
||||
readonly outcome: ConnectorLeaseAuditOutcome;
|
||||
readonly connectorId: string;
|
||||
readonly correlationId: string;
|
||||
readonly occurredAt: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
readonly reason?: ConnectorLeaseRejectReason;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
|
||||
readonly leaseId: string;
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
|
||||
readonly leaseId: string;
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
|
||||
readonly now: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseStore {
|
||||
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
|
||||
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
|
||||
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
|
||||
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
|
||||
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
|
||||
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
|
||||
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
|
||||
const logicalAgentId = requiredIdentifier(
|
||||
input.logicalAgentId,
|
||||
'logical agent ID',
|
||||
ID_PATTERN,
|
||||
true,
|
||||
);
|
||||
return Object.freeze({ tenantId, logicalAgentId });
|
||||
}
|
||||
|
||||
export function normalizeLogicalBindingId(value: string): string {
|
||||
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeConnectorId(value: string): string {
|
||||
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeCorrelationId(value: string): string {
|
||||
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
|
||||
}
|
||||
|
||||
export function normalizeConnectorScope(value: string): string {
|
||||
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
|
||||
if (values.length === 0) throw new Error('At least one connector scope is required');
|
||||
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
|
||||
}
|
||||
|
||||
export function normalizeLeaseEpoch(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
|
||||
const epoch = BigInt(trimmed);
|
||||
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
|
||||
return epoch.toString(10);
|
||||
}
|
||||
|
||||
function requiredIdentifier(
|
||||
value: string,
|
||||
label: string,
|
||||
pattern: RegExp,
|
||||
lowerCase: boolean,
|
||||
): string {
|
||||
const trimmed = value.trim();
|
||||
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
|
||||
if (!pattern.test(normalized)) {
|
||||
throw new Error(`${label} has an invalid normalized format`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Opaque handle for agent sessions — callers should not access internals */
|
||||
export interface AgentSessionHandle {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export * from './agent-runtime-provider.js';
|
||||
export * from './connector-lease.dto.js';
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
ChannelAdapterHealthDto,
|
||||
ChannelEgressDto,
|
||||
ChannelIngressDto,
|
||||
} from './channel.dto.js';
|
||||
|
||||
export type ChannelDeliveryErrorCode =
|
||||
| 'invalid_route'
|
||||
| 'destination_unavailable'
|
||||
| 'delivery_failed';
|
||||
|
||||
/** Terminal adapter delivery failure surfaced to the gateway/caller. */
|
||||
export class ChannelDeliveryError extends Error {
|
||||
readonly name = 'ChannelDeliveryError';
|
||||
|
||||
constructor(
|
||||
readonly code: ChannelDeliveryErrorCode,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway policy boundary consumed by official channel adapters. */
|
||||
export interface ChannelIngressPort {
|
||||
receive(ingress: ChannelIngressDto): Promise<void>;
|
||||
}
|
||||
|
||||
/** Adapter egress boundary used by the gateway after agent output is ready. */
|
||||
export interface ChannelEgressPort {
|
||||
send(egress: ChannelEgressDto): Promise<void>;
|
||||
}
|
||||
|
||||
/** Shared lifecycle seam implemented by every official channel adapter. */
|
||||
export interface OfficialChannelAdapter {
|
||||
readonly name: string;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Health is best-effort and never throws for ordinary disconnected state. */
|
||||
health(): Promise<ChannelAdapterHealthDto>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/** JSON-safe metadata carried across channel adapter boundaries. */
|
||||
export type ChannelMetadataValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| readonly ChannelMetadataValue[]
|
||||
| { readonly [key: string]: ChannelMetadataValue };
|
||||
|
||||
export type ChannelSenderKind = 'user' | 'agent' | 'system';
|
||||
export type ChannelContentKind = 'text' | 'markdown' | 'code' | 'image' | 'file';
|
||||
export type ChannelAdapterStatus = 'connected' | 'degraded' | 'disconnected';
|
||||
export type ChannelAuthorizationRole = 'viewer' | 'operator' | 'admin';
|
||||
export type ChannelOperation = 'message.send' | 'approval.create' | 'session.stop';
|
||||
|
||||
export interface ChannelAttachmentDto {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
url: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
/** Canonical transport-neutral message shape for official channel adapters. */
|
||||
export interface ChannelMessageDto {
|
||||
id: string;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderKind: ChannelSenderKind;
|
||||
content: string;
|
||||
contentKind: ChannelContentKind;
|
||||
timestamp: string;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
attachments?: readonly ChannelAttachmentDto[];
|
||||
metadata: Readonly<Record<string, ChannelMetadataValue>>;
|
||||
}
|
||||
|
||||
/** Where an adapter must deliver a response for one normalized conversation turn. */
|
||||
/** Provisioned external identity after adapter allowlist/pairing checks pass. */
|
||||
export interface ChannelAuthorizedPrincipalDto {
|
||||
channelUserId: string;
|
||||
role: ChannelAuthorizationRole;
|
||||
/** Needed when gateway policy must authorize a privileged Mosaic operation. */
|
||||
mosaicUserId?: string;
|
||||
}
|
||||
|
||||
/** Configuration-owned binding. Credentials are intentionally absent. */
|
||||
export interface ChannelBindingDto {
|
||||
bindingId: string;
|
||||
channelName: string;
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
logicalAgentId: string;
|
||||
principals: Readonly<Record<string, ChannelAuthorizedPrincipalDto>>;
|
||||
}
|
||||
|
||||
export interface ChannelResponseTargetDto {
|
||||
channelId: string;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable channel-to-session route. Runtime provider, harness, model, process,
|
||||
* and native runtime session identifiers are intentionally absent.
|
||||
*/
|
||||
export interface ChannelConversationRouteDto {
|
||||
bindingId: string;
|
||||
logicalAgentId: string;
|
||||
conversationId: string;
|
||||
channelName: string;
|
||||
authorizationChannelId: string;
|
||||
responseTarget: ChannelResponseTargetDto;
|
||||
}
|
||||
|
||||
/** Authorized adapter-to-gateway ingress after native translation. */
|
||||
export interface ChannelIngressDto {
|
||||
correlationId: string;
|
||||
nativeMessageId: string;
|
||||
operation: ChannelOperation;
|
||||
principal: ChannelAuthorizedPrincipalDto;
|
||||
message: ChannelMessageDto;
|
||||
route: ChannelConversationRouteDto;
|
||||
}
|
||||
|
||||
/** Gateway-to-adapter egress; runtime/provider identity remains gateway-internal. */
|
||||
export interface ChannelEgressDto {
|
||||
correlationId: string;
|
||||
message: ChannelMessageDto;
|
||||
route: ChannelConversationRouteDto;
|
||||
}
|
||||
|
||||
export interface ChannelAdapterHealthDto {
|
||||
status: ChannelAdapterStatus;
|
||||
detail?: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './channel-adapter.js';
|
||||
export * from './channel.dto.js';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class ChatMessageDto {
|
||||
@IsOptional()
|
||||
@IsUUID(4)
|
||||
conversationId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32_000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class ChatResponseDto {
|
||||
conversationId!: string;
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { ChannelAttachmentDto } from '../channel/index.js';
|
||||
import type {
|
||||
CommandManifestPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
} from '../commands/index.js';
|
||||
import type { HarnessErrorCode, HarnessSelection, HarnessTurnState } from '../harness/index.js';
|
||||
|
||||
export interface MessageAckPayload {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface AgentStartPayload {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface AgentEndPayload {
|
||||
conversationId: string;
|
||||
usage?: SessionUsagePayload;
|
||||
}
|
||||
|
||||
/** Session metadata emitted with agent:end and on session:info */
|
||||
export interface SessionUsagePayload {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
cost: number;
|
||||
context: {
|
||||
percent: number | null;
|
||||
window: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentTextPayload {
|
||||
conversationId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AgentThinkingPayload {
|
||||
conversationId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolStartPayload {
|
||||
conversationId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface ToolEndPayload {
|
||||
conversationId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorPayload {
|
||||
conversationId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ChatMessagePayload {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
agentId?: string;
|
||||
attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/** Routing decision summary included in session:info for transparency */
|
||||
export interface RoutingDecisionInfo {
|
||||
model: string;
|
||||
provider: string;
|
||||
ruleName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Session info pushed when session is created or model changes */
|
||||
export interface SessionInfoPayload {
|
||||
conversationId: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Present when automatic routing determined the model for this session */
|
||||
routingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/** Client request to change thinking level */
|
||||
export interface SetThinkingPayload {
|
||||
conversationId: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
/** Client request to abort the current agent operation */
|
||||
export interface AbortPayload {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen P3 `turn:send` wire contract (Task Five; reused unchanged by Tasks 15 and 16).
|
||||
* Accepts no attachments or authority-bearing fields in Slice Zero. Gateway validation
|
||||
* requires a UUID conversation id, non-empty bounded content, a nested selection with exactly
|
||||
* `harnessId`/`providerId`/`modelId` (each 1..255 chars), and a UUID-v4 idempotency key; it
|
||||
* rejects unknown fields, top-level `provider`/`modelId`, malformed nesting, and empty values
|
||||
* before any runtime dispatch.
|
||||
*/
|
||||
export interface HarnessTurnSendPayload {
|
||||
readonly conversationId: string; // UUID; required before send
|
||||
readonly content: string; // trimmed, 1..10_000 characters
|
||||
readonly selection: HarnessSelection; // nested; exactly three ids
|
||||
readonly idempotencyKey: string; // browser-generated UUID v4
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen `turn:ack` wire contract. Success echoes the accepted idempotency key and the
|
||||
* exact requested selection tuple; failure carries only fixed/safe text and never a
|
||||
* substituted effective selection or raw exception text.
|
||||
*/
|
||||
export type HarnessTurnAckPayload =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly conversationId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly turnId: string;
|
||||
readonly correlationId: string;
|
||||
readonly state: HarnessTurnState;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly conversationId?: string;
|
||||
readonly idempotencyKey?: string;
|
||||
readonly code: HarnessErrorCode | 'request_invalid' | 'runtime_unsupported';
|
||||
readonly message: string; // fixed/safe text only
|
||||
readonly retryable: boolean;
|
||||
readonly correlationId: string;
|
||||
/** Present only when a complete tuple was validated; always the requested tuple. */
|
||||
readonly selection?: HarnessSelection;
|
||||
};
|
||||
|
||||
/**
|
||||
* The frozen browser send-protocol advertisement (Task Five; server → client only).
|
||||
*
|
||||
* A conversation id or a harness selection never proves that the connected Gateway actually
|
||||
* handles a given wire event, so after BetterAuth authenticates a browser Socket connection the
|
||||
* Gateway advertises — exactly once, targeted to that socket — which send event the client may
|
||||
* use. `legacy-message` in legacy mode, `unavailable` in `pi-rpc` (including test-ready Pi
|
||||
* graphs); Task Five never advertises `turn-send` (its authenticated handler lands in Task 15).
|
||||
* Capability is routing information, never authorization: every server handler still enforces
|
||||
* authentication, ownership, DTO, mode, and runtime checks.
|
||||
*/
|
||||
export type ChatSendProtocol = 'legacy-message' | 'turn-send' | 'unavailable';
|
||||
|
||||
export interface ChatSendCapabilityPayload {
|
||||
readonly protocol: ChatSendProtocol;
|
||||
/** Exact Socket.IO id for the authenticated browser connection this advertisement is bound to. */
|
||||
readonly connectionId: string;
|
||||
}
|
||||
|
||||
/** Socket.IO typed event map: server → client */
|
||||
export interface ServerToClientEvents {
|
||||
'chat:send-capability': (payload: ChatSendCapabilityPayload) => void;
|
||||
'message:ack': (payload: MessageAckPayload) => void;
|
||||
'agent:start': (payload: AgentStartPayload) => void;
|
||||
'agent:end': (payload: AgentEndPayload) => void;
|
||||
'agent:text': (payload: AgentTextPayload) => void;
|
||||
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
||||
'agent:tool:start': (payload: ToolStartPayload) => void;
|
||||
'agent:tool:end': (payload: ToolEndPayload) => void;
|
||||
'session:info': (payload: SessionInfoPayload) => void;
|
||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||
'system:reload': (payload: SystemReloadPayload) => void;
|
||||
'turn:ack': (payload: HarnessTurnAckPayload) => void;
|
||||
error: (payload: ErrorPayload) => void;
|
||||
}
|
||||
|
||||
/** Socket.IO typed event map: client → server */
|
||||
export interface ClientToServerEvents {
|
||||
message: (data: ChatMessagePayload) => void;
|
||||
'turn:send': (data: HarnessTurnSendPayload) => void;
|
||||
'set:thinking': (data: SetThinkingPayload) => void;
|
||||
'command:execute': (data: SlashCommandPayload) => void;
|
||||
'command:approve': (data: SlashCommandPayload) => void;
|
||||
abort: (data: AbortPayload) => void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { ChatMessageDto, ChatResponseDto } from './chat.dto.js';
|
||||
export type {
|
||||
MessageAckPayload,
|
||||
AgentStartPayload,
|
||||
AgentEndPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionUsagePayload,
|
||||
SessionInfoPayload,
|
||||
RoutingDecisionInfo,
|
||||
SetThinkingPayload,
|
||||
AbortPayload,
|
||||
ErrorPayload,
|
||||
ChatMessagePayload,
|
||||
HarnessTurnSendPayload,
|
||||
HarnessTurnAckPayload,
|
||||
ChatSendProtocol,
|
||||
ChatSendCapabilityPayload,
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
} from './events.js';
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Argument definition for a slash command */
|
||||
export interface CommandArgDef {
|
||||
name: string;
|
||||
type: 'string' | 'enum';
|
||||
optional: boolean;
|
||||
/** For enum type, the allowed values */
|
||||
values?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A single command definition served by the gateway */
|
||||
export interface CommandDef {
|
||||
/** Command name without slash prefix, e.g. "model" */
|
||||
name: string;
|
||||
/** Short aliases, e.g. ["m"] */
|
||||
aliases: string[];
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
/** Argument schema */
|
||||
args?: CommandArgDef[];
|
||||
/** Nested subcommands (e.g. provider → login, logout) */
|
||||
subcommands?: CommandDef[];
|
||||
/** Origin of this command */
|
||||
scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin';
|
||||
/** Where the command executes */
|
||||
execution: 'local' | 'socket' | 'rest' | 'hybrid';
|
||||
/** Whether this command is currently available */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Full command manifest pushed from gateway to TUI */
|
||||
export interface CommandManifest {
|
||||
commands: CommandDef[];
|
||||
skills: SkillCommandDef[];
|
||||
/** Manifest version — TUI compares to detect changes */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** Skill registered as /skill:name */
|
||||
export interface SkillCommandDef {
|
||||
/** Skill name (used as /skill:{name}) */
|
||||
name: string;
|
||||
description: string;
|
||||
/** Whether the skill is currently loaded and available */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Payload for commands:manifest event */
|
||||
export interface CommandManifestPayload {
|
||||
manifest: CommandManifest;
|
||||
}
|
||||
|
||||
/** Payload for system:reload broadcast */
|
||||
export interface SystemReloadPayload {
|
||||
commands: CommandDef[];
|
||||
skills: SkillCommandDef[];
|
||||
providers: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Client request to execute a slash command via socket */
|
||||
export interface SlashCommandPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
args?: string;
|
||||
/** One-time server-issued approval for a privileged command execution. */
|
||||
approvalId?: string;
|
||||
}
|
||||
|
||||
/** Server response to a request to approve a privileged slash command. */
|
||||
export interface SlashCommandApprovalResultPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
approvalId?: string;
|
||||
expiresAt?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Server response to a slash command */
|
||||
export interface SlashCommandResultPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Parsed slash command (TUI-side, not a socket type) */
|
||||
export interface ParsedCommand {
|
||||
/** Command name without slash, e.g. "model", "skill:brave-search" */
|
||||
command: string;
|
||||
/** Arguments string or null */
|
||||
args: string | null;
|
||||
/** Full raw input string */
|
||||
raw: string;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Unit tests for federation wire-format DTOs.
|
||||
*
|
||||
* Coverage:
|
||||
* - FederationRequestSchema (valid + invalid)
|
||||
* - FederationListResponseSchema factory
|
||||
* - FederationGetResponseSchema factory
|
||||
* - FederationCapabilitiesResponseSchema
|
||||
* - FederationErrorEnvelopeSchema + error code exhaustiveness
|
||||
* - FederationError exception hierarchy
|
||||
* - tagWithSource helper round-trip
|
||||
* - SourceTagSchema
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
FEDERATION_ERROR_CODES,
|
||||
FEDERATION_VERBS,
|
||||
FederationCapabilitiesResponseSchema,
|
||||
FederationError,
|
||||
FederationErrorEnvelopeSchema,
|
||||
FederationForbiddenError,
|
||||
FederationInternalError,
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationRateLimitedError,
|
||||
FederationRequestSchema,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
FederationGetResponseSchema,
|
||||
FederationListResponseSchema,
|
||||
SOURCE_LOCAL,
|
||||
SourceTagSchema,
|
||||
parseFederationErrorEnvelope,
|
||||
tagWithSource,
|
||||
} from '../index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verbs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FEDERATION_VERBS', () => {
|
||||
it('contains exactly list, get, capabilities', () => {
|
||||
expect(FEDERATION_VERBS).toEqual(['list', 'get', 'capabilities']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationRequestSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationRequestSchema', () => {
|
||||
it('accepts a minimal valid list request', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'list', resource: 'tasks' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a get request with cursor and params', () => {
|
||||
const result = FederationRequestSchema.safeParse({
|
||||
verb: 'get',
|
||||
resource: 'notes',
|
||||
cursor: 'abc123',
|
||||
params: { filter: 'mine' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.cursor).toBe('abc123');
|
||||
expect(result.data.params?.['filter']).toBe('mine');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a capabilities request', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'capabilities', resource: 'tasks' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown verb', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'search', resource: 'tasks' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty resource string', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'list', resource: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing verb', () => {
|
||||
const result = FederationRequestSchema.safeParse({ resource: 'tasks' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationListResponseSchema factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationListResponseSchema', () => {
|
||||
const ItemSchema = z.object({ id: z.string(), name: z.string() });
|
||||
const ListSchema = FederationListResponseSchema(ItemSchema);
|
||||
|
||||
it('accepts a valid list envelope', () => {
|
||||
const result = ListSchema.safeParse({
|
||||
items: [{ id: '1', name: 'Task A' }],
|
||||
nextCursor: 'page2',
|
||||
_partial: false,
|
||||
_truncated: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.data.nextCursor).toBe('page2');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a minimal envelope with empty items', () => {
|
||||
const result = ListSchema.safeParse({ items: [] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when items is missing', () => {
|
||||
const result = ListSchema.safeParse({ nextCursor: 'x' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when an item fails validation', () => {
|
||||
const result = ListSchema.safeParse({ items: [{ id: 1, name: 'bad' }] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationGetResponseSchema factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationGetResponseSchema', () => {
|
||||
const ItemSchema = z.object({ id: z.string() });
|
||||
const GetSchema = FederationGetResponseSchema(ItemSchema);
|
||||
|
||||
it('accepts a found item', () => {
|
||||
const result = GetSchema.safeParse({ item: { id: 'abc' } });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item).toEqual({ id: 'abc' });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts null item (not found)', () => {
|
||||
const result = GetSchema.safeParse({ item: null });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when item is missing', () => {
|
||||
const result = GetSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationCapabilitiesResponseSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationCapabilitiesResponseSchema', () => {
|
||||
it('accepts a valid capabilities response', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 500,
|
||||
supported_verbs: ['list', 'get', 'capabilities'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.max_rows_per_query).toBe(500);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a response with filters field', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
filters: {
|
||||
tasks: { include_teams: ['team-a'], include_personal: true },
|
||||
notes: { include_personal: false },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.filters?.['tasks']?.include_teams).toEqual(['team-a']);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a response with partial filters (only include_teams)', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
supported_verbs: ['list'],
|
||||
filters: { tasks: { include_teams: ['eng'] } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a response with rate_limit (M4 full shape)', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
rate_limit: { limit_per_minute: 60, remaining: 55, reset_at: '2026-04-23T12:00:00Z' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.rate_limit?.limit_per_minute).toBe(60);
|
||||
expect(result.data.rate_limit?.remaining).toBe(55);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a response with rate_limit (M3 minimal — limit_per_minute only)', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
rate_limit: { limit_per_minute: 120 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a response without rate_limit (field is optional)', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.rate_limit).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects rate_limit with non-positive limit_per_minute', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
rate_limit: { limit_per_minute: 0 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects rate_limit with invalid reset_at datetime', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
rate_limit: { limit_per_minute: 60, reset_at: 'not-a-datetime' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects supported_verbs with an invalid verb (MED-3 enum guard)', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['invalid_verb'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty resources array', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: [],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-integer max_rows_per_query', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 1.5,
|
||||
supported_verbs: ['list'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationErrorEnvelopeSchema + error code exhaustiveness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationErrorEnvelopeSchema', () => {
|
||||
it('accepts each valid error code', () => {
|
||||
for (const code of FEDERATION_ERROR_CODES) {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code, message: 'test' },
|
||||
});
|
||||
expect(result.success, `code ${code} should be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown error code', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code: 'unknown_code', message: 'test' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional details field', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code: 'forbidden', message: 'nope', details: { grantId: 'xyz' } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when message is missing', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({ error: { code: 'not_found' } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFederationErrorEnvelope', () => {
|
||||
it('returns a typed envelope for valid input', () => {
|
||||
const env = parseFederationErrorEnvelope({ error: { code: 'not_found', message: 'gone' } });
|
||||
expect(env.error.code).toBe('not_found');
|
||||
});
|
||||
|
||||
it('throws for invalid input', () => {
|
||||
expect(() => parseFederationErrorEnvelope({ bad: 'shape' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationError exception hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationError hierarchy', () => {
|
||||
const cases: Array<[string, FederationError]> = [
|
||||
['unauthorized', new FederationUnauthorizedError()],
|
||||
['forbidden', new FederationForbiddenError()],
|
||||
['not_found', new FederationNotFoundError()],
|
||||
['rate_limited', new FederationRateLimitedError()],
|
||||
['scope_violation', new FederationScopeViolationError()],
|
||||
['invalid_request', new FederationInvalidRequestError()],
|
||||
['internal_error', new FederationInternalError()],
|
||||
];
|
||||
|
||||
it.each(cases)('code %s is an instance of FederationError', (_code, err) => {
|
||||
expect(err).toBeInstanceOf(FederationError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it.each(cases)('code %s has correct code property', (code, err) => {
|
||||
expect(err.code).toBe(code);
|
||||
});
|
||||
|
||||
it('toEnvelope serialises to wire format', () => {
|
||||
const err = new FederationForbiddenError('Access denied', { grantId: 'g1' });
|
||||
const env = err.toEnvelope();
|
||||
expect(env.error.code).toBe('forbidden');
|
||||
expect(env.error.message).toBe('Access denied');
|
||||
expect(env.error.details).toEqual({ grantId: 'g1' });
|
||||
});
|
||||
|
||||
it('toEnvelope omits details when not provided', () => {
|
||||
const err = new FederationNotFoundError();
|
||||
const env = err.toEnvelope();
|
||||
expect(Object.prototype.hasOwnProperty.call(env.error, 'details')).toBe(false);
|
||||
});
|
||||
|
||||
it('error codes tuple covers all subclasses (exhaustiveness check)', () => {
|
||||
// If a new subclass is added without a code, this test fails at compile time.
|
||||
const allCodes = new Set(FEDERATION_ERROR_CODES);
|
||||
for (const [code] of cases) {
|
||||
expect(allCodes.has(code as (typeof FEDERATION_ERROR_CODES)[number])).toBe(true);
|
||||
}
|
||||
// All codes are covered by at least one case
|
||||
expect(cases).toHaveLength(FEDERATION_ERROR_CODES.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source tag + tagWithSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SourceTagSchema', () => {
|
||||
it('accepts a non-empty _source string', () => {
|
||||
expect(SourceTagSchema.safeParse({ _source: 'local' }).success).toBe(true);
|
||||
expect(SourceTagSchema.safeParse({ _source: 'mosaic.uscllc.com' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty _source string', () => {
|
||||
expect(SourceTagSchema.safeParse({ _source: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagWithSource', () => {
|
||||
it('stamps each item with the given source', () => {
|
||||
const items = [{ id: '1' }, { id: '2' }];
|
||||
const tagged = tagWithSource(items, SOURCE_LOCAL);
|
||||
expect(tagged).toEqual([
|
||||
{ id: '1', _source: 'local' },
|
||||
{ id: '2', _source: 'local' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves original item fields', () => {
|
||||
const items = [{ id: 'x', name: 'Task', done: false }];
|
||||
const tagged = tagWithSource(items, 'mosaic.uscllc.com');
|
||||
expect(tagged[0]).toMatchObject({ id: 'x', name: 'Task', done: false });
|
||||
expect(tagged[0]?._source).toBe('mosaic.uscllc.com');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(tagWithSource([], 'local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trip: tagWithSource output passes SourceTagSchema', () => {
|
||||
const tagged = tagWithSource([{ id: '1' }], 'local');
|
||||
expect(SourceTagSchema.safeParse(tagged[0]).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Federation wire-format error envelope and exception hierarchy.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §6, §8.
|
||||
*
|
||||
* DESIGN: Typed error classes rather than discriminated union values
|
||||
* ──────────────────────────────────────────────────────────────────
|
||||
* We expose:
|
||||
* 1. `FEDERATION_ERROR_CODES` — closed string-enum tuple (exhaustiveness-checkable).
|
||||
* 2. `FederationErrorCode` — union type inferred from the tuple.
|
||||
* 3. `FederationErrorEnvelopeSchema` — Zod schema for the wire format.
|
||||
* 4. `FederationError` — base Error subclass with a typed `code` property.
|
||||
* One concrete subclass per code (e.g. `FederationUnauthorizedError`),
|
||||
* which enables `instanceof` dispatch in handlers without a switch.
|
||||
*
|
||||
* Rationale: subclasses give gateway handlers and the client a clean dispatch
|
||||
* point (catch + instanceof) without re-parsing or switch tables. All classes
|
||||
* carry `code` so a generic logger can act on any FederationError uniformly.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error code enum (closed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FEDERATION_ERROR_CODES = [
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'not_found',
|
||||
'rate_limited',
|
||||
'scope_violation',
|
||||
'invalid_request',
|
||||
'internal_error',
|
||||
] as const;
|
||||
|
||||
export type FederationErrorCode = (typeof FEDERATION_ERROR_CODES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire-format schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FederationErrorEnvelopeSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.enum(FEDERATION_ERROR_CODES),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type FederationErrorEnvelope = z.infer<typeof FederationErrorEnvelopeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exception class hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Base class for all federation errors.
|
||||
* Carries a typed `code` so handlers can act uniformly on any FederationError.
|
||||
*/
|
||||
export class FederationError extends Error {
|
||||
readonly code: FederationErrorCode;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(code: FederationErrorCode, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'FederationError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/** Serialise to the wire-format error envelope. */
|
||||
toEnvelope(): FederationErrorEnvelope {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.details !== undefined ? { details: this.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Client cert is missing, invalid, or signed by an untrusted CA. */
|
||||
export class FederationUnauthorizedError extends FederationError {
|
||||
constructor(message = 'Unauthorized', details?: unknown) {
|
||||
super('unauthorized', message, details);
|
||||
this.name = 'FederationUnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant is inactive, revoked, or the subject user lacks access to the resource. */
|
||||
export class FederationForbiddenError extends FederationError {
|
||||
constructor(message = 'Forbidden', details?: unknown) {
|
||||
super('forbidden', message, details);
|
||||
this.name = 'FederationForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Requested resource does not exist. */
|
||||
export class FederationNotFoundError extends FederationError {
|
||||
constructor(message = 'Not found', details?: unknown) {
|
||||
super('not_found', message, details);
|
||||
this.name = 'FederationNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant has exceeded its rate limit; Retry-After should accompany this. */
|
||||
export class FederationRateLimitedError extends FederationError {
|
||||
constructor(message = 'Rate limit exceeded', details?: unknown) {
|
||||
super('rate_limited', message, details);
|
||||
this.name = 'FederationRateLimitedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The request targets a resource or performs an action that the grant's
|
||||
* scope explicitly disallows (distinct from generic 403 — scope_violation
|
||||
* means the scope configuration itself blocked the request).
|
||||
*/
|
||||
export class FederationScopeViolationError extends FederationError {
|
||||
constructor(message = 'Scope violation', details?: unknown) {
|
||||
super('scope_violation', message, details);
|
||||
this.name = 'FederationScopeViolationError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Malformed request — missing fields, invalid cursor, unknown verb, etc. */
|
||||
export class FederationInvalidRequestError extends FederationError {
|
||||
constructor(message = 'Invalid request', details?: unknown) {
|
||||
super('invalid_request', message, details);
|
||||
this.name = 'FederationInvalidRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Unexpected server-side failure. */
|
||||
export class FederationInternalError extends FederationError {
|
||||
constructor(message = 'Internal error', details?: unknown) {
|
||||
super('internal_error', message, details);
|
||||
this.name = 'FederationInternalError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse an unknown value as a FederationErrorEnvelope.
|
||||
* Throws a plain Error (not FederationError) when parsing fails — this means
|
||||
* the payload wasn't even a valid error envelope.
|
||||
*/
|
||||
export function parseFederationErrorEnvelope(input: unknown): FederationErrorEnvelope {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid federation error envelope:\n${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Federation wire-format DTOs — public barrel.
|
||||
*
|
||||
* Exports everything downstream M3 tasks need:
|
||||
* verbs.ts — FEDERATION_VERBS constant + FederationVerb type
|
||||
* request.ts — FederationRequestSchema + FederationRequest
|
||||
* response.ts — list/get/capabilities schema factories + types
|
||||
* source-tag.ts — SourceTagSchema, tagWithSource helper
|
||||
* error.ts — error envelope schema + typed exception hierarchy
|
||||
*/
|
||||
|
||||
export * from './verbs.js';
|
||||
export * from './request.js';
|
||||
export * from './response.js';
|
||||
export * from './source-tag.js';
|
||||
export * from './error.js';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Federation wire-format request schema.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9 (query model).
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { FEDERATION_VERBS } from './verbs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query params — free-form key/value pairs passed alongside the request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const QueryParamsSchema = z.record(z.string(), z.string()).optional();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level request schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FederationRequestSchema = z.object({
|
||||
/**
|
||||
* Verb being invoked. One of the M3 federation verbs.
|
||||
*/
|
||||
verb: z.enum(FEDERATION_VERBS),
|
||||
|
||||
/**
|
||||
* Resource path being queried, e.g. "tasks", "notes", "memory".
|
||||
* Forward-slash-separated for sub-resources (e.g. "teams/abc/tasks").
|
||||
*/
|
||||
resource: z.string().min(1, { message: 'resource must not be empty' }),
|
||||
|
||||
/**
|
||||
* Optional free-form query params (filters, sort, etc.).
|
||||
* Values are always strings; consumers parse as needed.
|
||||
*/
|
||||
params: QueryParamsSchema,
|
||||
|
||||
/**
|
||||
* Opaque pagination cursor returned by a previous list response.
|
||||
* Absent on first page.
|
||||
*/
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FederationRequest = z.infer<typeof FederationRequestSchema>;
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Federation wire-format response schemas.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9 and MILESTONES.md §M3.
|
||||
*
|
||||
* DESIGN: Generic factory functions rather than z.lazy
|
||||
* ─────────────────────────────────────────────────────
|
||||
* Zod generic schemas cannot be expressed as a single re-usable `z.ZodType`
|
||||
* value because TypeScript's type system erases the generic at the call site.
|
||||
* The idiomatic Zod v4 pattern is factory functions that take an item schema
|
||||
* and return a fully-typed schema.
|
||||
*
|
||||
* const MyListSchema = FederationListResponseSchema(z.string());
|
||||
* type MyList = z.infer<typeof MyListSchema>;
|
||||
* // => { items: string[]; nextCursor?: string; _partial?: boolean; _truncated?: boolean }
|
||||
*
|
||||
* Downstream consumers (M3-03..M3-07, M3-08, M3-09) should call these
|
||||
* factories once per resource type and cache the result.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FEDERATION_VERBS } from './verbs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared envelope flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `_partial`: true when the response is a subset of available data (e.g. due
|
||||
* to scope intersection reducing the result set).
|
||||
*/
|
||||
const PartialFlag = z.boolean().optional();
|
||||
|
||||
/**
|
||||
* `_truncated`: true when the response was capped by max_rows_per_query and
|
||||
* additional pages exist beyond the current cursor.
|
||||
*/
|
||||
const TruncatedFlag = z.boolean().optional();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationListResponseSchema<T> factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Zod schema for a paginated federation list envelope.
|
||||
*
|
||||
* @param itemSchema - Zod schema for a single item in the list.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const TaskListSchema = FederationListResponseSchema(TaskSchema);
|
||||
* type TaskList = z.infer<typeof TaskListSchema>;
|
||||
* ```
|
||||
*/
|
||||
export function FederationListResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
items: z.array(itemSchema),
|
||||
nextCursor: z.string().optional(),
|
||||
_partial: PartialFlag,
|
||||
_truncated: TruncatedFlag,
|
||||
});
|
||||
}
|
||||
|
||||
export type FederationListResponse<T> = {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
_partial?: boolean;
|
||||
_truncated?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationGetResponseSchema<T> factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Zod schema for a single-item federation get envelope.
|
||||
*
|
||||
* `item` is null when the resource was not found (404 equivalent on the wire).
|
||||
*
|
||||
* @param itemSchema - Zod schema for the item (nullable is applied internally).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const TaskGetSchema = FederationGetResponseSchema(TaskSchema);
|
||||
* type TaskGet = z.infer<typeof TaskGetSchema>;
|
||||
* ```
|
||||
*/
|
||||
export function FederationGetResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
item: itemSchema.nullable(),
|
||||
_partial: PartialFlag,
|
||||
});
|
||||
}
|
||||
|
||||
export type FederationGetResponse<T> = {
|
||||
item: T | null;
|
||||
_partial?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationCapabilitiesResponseSchema (fixed shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shape mirrors FederationScope (apps/gateway/src/federation/scope-schema.ts)
|
||||
* but is kept separate to avoid coupling packages/types to the gateway module.
|
||||
* The serving side populates this from the resolved grant scope at request time.
|
||||
*/
|
||||
export const FederationCapabilitiesResponseSchema = z.object({
|
||||
/**
|
||||
* Resources this grant is allowed to query.
|
||||
*/
|
||||
resources: z.array(z.string()).nonempty(),
|
||||
|
||||
/**
|
||||
* Resources explicitly blocked for this grant even if they exist.
|
||||
*/
|
||||
excluded_resources: z.array(z.string()),
|
||||
|
||||
/**
|
||||
* Per-resource filters (mirrors FederationScope.filters from PRD §8.1).
|
||||
* Keys are resource names; values control team/personal visibility.
|
||||
*/
|
||||
filters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
include_teams: z.array(z.string()).optional(),
|
||||
include_personal: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
/**
|
||||
* Hard cap on rows returned per query for this grant.
|
||||
*/
|
||||
max_rows_per_query: z.number().int().positive(),
|
||||
|
||||
/**
|
||||
* Verbs currently available. Will expand in M4+ (search).
|
||||
* Closed enum — only values from FEDERATION_VERBS are accepted.
|
||||
*/
|
||||
supported_verbs: z.array(z.enum(FEDERATION_VERBS)).nonempty(),
|
||||
|
||||
/**
|
||||
* Rate-limit state for this grant (PRD §9.1).
|
||||
* M4 populates `remaining` and `reset_at`; M3 servers may return only
|
||||
* `limit_per_minute` or omit the field entirely.
|
||||
*/
|
||||
rate_limit: z
|
||||
.object({
|
||||
limit_per_minute: z.number().int().positive(),
|
||||
remaining: z.number().int().nonnegative().optional(),
|
||||
reset_at: z.string().datetime().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type FederationCapabilitiesResponse = z.infer<typeof FederationCapabilitiesResponseSchema>;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* _source tag for federation fan-out results.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9.3 and MILESTONES.md §M3 acceptance test #8.
|
||||
*
|
||||
* When source: "all" is requested, the gateway fans out to local + all active
|
||||
* federated peers, merges results, and tags each item with _source so the
|
||||
* caller knows the provenance.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source tag schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `_source` is either:
|
||||
* - `"local"` — the item came from this gateway's own storage.
|
||||
* - a peer common name (e.g. `"mosaic.uscllc.com"`) — the item came from
|
||||
* that federated peer.
|
||||
*/
|
||||
export const SourceTagSchema = z.object({
|
||||
_source: z.string().min(1, { message: '_source must not be empty' }),
|
||||
});
|
||||
|
||||
export type SourceTag = z.infer<typeof SourceTagSchema>;
|
||||
|
||||
/**
|
||||
* Literal union for the well-known local source value.
|
||||
* Peers are identified by hostname strings, so there is no closed enum.
|
||||
*/
|
||||
export const SOURCE_LOCAL = 'local' as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: tagWithSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stamps each item in `items` with `{ _source: source }`.
|
||||
*
|
||||
* The return type merges the item type with SourceTag so callers get full
|
||||
* type-safety on both the original fields and `_source`.
|
||||
*
|
||||
* @param items - Array of items to tag.
|
||||
* @param source - Either `"local"` or a peer hostname (common name from the
|
||||
* client cert's CN or O field).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const local = tagWithSource([{ id: '1', title: 'Task' }], 'local');
|
||||
* // => [{ id: '1', title: 'Task', _source: 'local' }]
|
||||
*
|
||||
* const remote = tagWithSource(peerItems, 'mosaic.uscllc.com');
|
||||
* ```
|
||||
*/
|
||||
export function tagWithSource<T extends object>(items: T[], source: string): Array<T & SourceTag> {
|
||||
return items.map((item) => ({ ...item, _source: source }));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Federation verb constants and types.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9.1
|
||||
*
|
||||
* M3 ships list, get, capabilities. search lives in M4.
|
||||
*/
|
||||
|
||||
export const FEDERATION_VERBS = ['list', 'get', 'capabilities'] as const;
|
||||
|
||||
export type FederationVerb = (typeof FEDERATION_VERBS)[number];
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||
import { HARNESS_CAPABILITIES, HARNESS_ERROR_CODES } from './index.js';
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
CreateHarnessSession,
|
||||
HarnessAdapter,
|
||||
HarnessCapability,
|
||||
HarnessConversationService,
|
||||
HarnessDescriptor,
|
||||
HarnessError,
|
||||
HarnessErrorCode,
|
||||
HarnessEvent,
|
||||
HarnessEventEnvelope,
|
||||
HarnessInteractionState,
|
||||
HarnessPrompt,
|
||||
HarnessPromptReceipt,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
ResumeHarnessSession,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '../index.js';
|
||||
|
||||
const EXPECTED_CAPABILITIES = [
|
||||
'modelSelection',
|
||||
'thinkingLevels',
|
||||
'images',
|
||||
'toolEvents',
|
||||
'extensionUi',
|
||||
'steering',
|
||||
'followUp',
|
||||
'compaction',
|
||||
'persistentResume',
|
||||
] as const satisfies readonly HarnessCapability[];
|
||||
|
||||
const EXPECTED_ERROR_CODES = [
|
||||
'auth_required',
|
||||
'selection_invalid',
|
||||
'catalog_unavailable',
|
||||
'catalog_stale',
|
||||
'model_unavailable',
|
||||
'no_viable_provider',
|
||||
'session_create_failed',
|
||||
'session_not_found',
|
||||
'resume_conflict',
|
||||
'session_busy',
|
||||
'auth_bundle_concurrency_unverified',
|
||||
'adapter_unavailable',
|
||||
'sandbox_unavailable',
|
||||
'rpc_version_unsupported',
|
||||
'rpc_protocol_error',
|
||||
'process_exited',
|
||||
'outcome_unknown',
|
||||
'interaction_unsupported',
|
||||
'aborted',
|
||||
] as const satisfies readonly HarnessErrorCode[];
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected contract variant: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function describeEvent(event: HarnessEvent): string {
|
||||
switch (event.type) {
|
||||
case 'session.started':
|
||||
case 'session.state':
|
||||
case 'session.identity_changed':
|
||||
case 'turn.started':
|
||||
case 'text.delta':
|
||||
case 'thinking.delta':
|
||||
case 'tool.started':
|
||||
case 'tool.updated':
|
||||
case 'tool.finished':
|
||||
case 'interaction.required':
|
||||
case 'usage.updated':
|
||||
case 'turn.completed':
|
||||
case 'error':
|
||||
return event.type;
|
||||
default:
|
||||
return assertNever(event);
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(error: HarnessError): HarnessErrorCode {
|
||||
switch (error.code) {
|
||||
case 'auth_required':
|
||||
case 'selection_invalid':
|
||||
case 'catalog_unavailable':
|
||||
case 'catalog_stale':
|
||||
case 'model_unavailable':
|
||||
case 'no_viable_provider':
|
||||
case 'session_create_failed':
|
||||
case 'session_not_found':
|
||||
case 'resume_conflict':
|
||||
case 'session_busy':
|
||||
case 'auth_bundle_concurrency_unverified':
|
||||
case 'adapter_unavailable':
|
||||
case 'sandbox_unavailable':
|
||||
case 'rpc_version_unsupported':
|
||||
case 'rpc_protocol_error':
|
||||
case 'process_exited':
|
||||
case 'outcome_unknown':
|
||||
case 'interaction_unsupported':
|
||||
case 'aborted':
|
||||
return error.code;
|
||||
default:
|
||||
return assertNever(error);
|
||||
}
|
||||
}
|
||||
|
||||
describe('generic harness contracts', (): void => {
|
||||
it('keeps harness, provider, model, conversation, native session, process, and seat separate', (): void => {
|
||||
const selection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai-codex',
|
||||
modelId: 'gpt-5-codex',
|
||||
} satisfies HarnessSelection;
|
||||
const snapshot = {
|
||||
conversationId: 'conversation-1',
|
||||
nativeSessionId: 'native-session-1',
|
||||
processId: 'process-1',
|
||||
seatId: 'seat-1',
|
||||
selection,
|
||||
state: 'idle',
|
||||
attachedClientIds: ['browser-1'],
|
||||
} satisfies HarnessSessionSnapshot;
|
||||
|
||||
const identifiers = [
|
||||
snapshot.selection.harnessId,
|
||||
snapshot.selection.providerId,
|
||||
snapshot.selection.modelId,
|
||||
snapshot.conversationId,
|
||||
snapshot.nativeSessionId,
|
||||
snapshot.processId,
|
||||
snapshot.seatId,
|
||||
];
|
||||
|
||||
expect(new Set(identifiers).size).toBe(7);
|
||||
expect(snapshot).toMatchObject({
|
||||
conversationId: 'conversation-1',
|
||||
nativeSessionId: 'native-session-1',
|
||||
processId: 'process-1',
|
||||
seatId: 'seat-1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the complete capability set as checked literals', (): void => {
|
||||
const descriptor = {
|
||||
id: 'pi',
|
||||
displayName: 'Pi',
|
||||
capabilities: HARNESS_CAPABILITIES,
|
||||
} satisfies HarnessDescriptor;
|
||||
|
||||
expect(HARNESS_CAPABILITIES).toEqual(EXPECTED_CAPABILITIES);
|
||||
expect(descriptor.capabilities).toEqual(EXPECTED_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('exposes every stable error code as an exhaustive discriminated union', (): void => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai-codex',
|
||||
modelId: 'gpt-5-codex',
|
||||
};
|
||||
const error: HarnessError = {
|
||||
code: 'model_unavailable',
|
||||
message: 'The selected model is unavailable.',
|
||||
retryable: true,
|
||||
correlationId: 'correlation-1',
|
||||
selection,
|
||||
};
|
||||
|
||||
expect(HARNESS_ERROR_CODES).toEqual(EXPECTED_ERROR_CODES);
|
||||
expect(describeError(error)).toBe('model_unavailable');
|
||||
const receipt = {
|
||||
conversationId: 'conversation-1',
|
||||
turnId: 'turn-1',
|
||||
correlationId: 'correlation-1',
|
||||
state: 'accepted',
|
||||
selection,
|
||||
} satisfies HarnessPromptReceipt;
|
||||
|
||||
expect(error.selection).toEqual(selection);
|
||||
expect(receipt.selection).toEqual(selection);
|
||||
expect('effectiveSelection' in error).toBe(false);
|
||||
expect('effectiveSelection' in receipt).toBe(false);
|
||||
});
|
||||
|
||||
it('wraps every normalized event variant in the persisted envelope', (): void => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai-codex',
|
||||
modelId: 'gpt-5-codex',
|
||||
};
|
||||
const toolStarted: HarnessEvent = {
|
||||
type: 'tool.started',
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'read',
|
||||
};
|
||||
const events: readonly HarnessEvent[] = [
|
||||
{ type: 'session.started', state: 'idle' },
|
||||
{ type: 'session.state', state: 'busy' },
|
||||
{
|
||||
type: 'session.identity_changed',
|
||||
identityGeneration: 2,
|
||||
label: 'Re-enrolled account',
|
||||
},
|
||||
{ type: 'turn.started' },
|
||||
{ type: 'text.delta', text: 'Hello' },
|
||||
{ type: 'thinking.delta', text: 'Reasoning' },
|
||||
toolStarted,
|
||||
{
|
||||
type: 'tool.updated',
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'read',
|
||||
message: 'Reading',
|
||||
},
|
||||
{
|
||||
type: 'tool.finished',
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'read',
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
type: 'interaction.required',
|
||||
requestId: 'interaction-1',
|
||||
interactionType: 'confirm',
|
||||
state: 'pending',
|
||||
prompt: 'Continue?',
|
||||
},
|
||||
{
|
||||
type: 'usage.updated',
|
||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||
},
|
||||
{ type: 'turn.completed', outcome: 'settled' },
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
code: 'rpc_protocol_error',
|
||||
message: 'The harness protocol failed.',
|
||||
retryable: false,
|
||||
correlationId: 'correlation-1',
|
||||
selection,
|
||||
},
|
||||
},
|
||||
];
|
||||
const envelope: HarnessEventEnvelope = {
|
||||
conversationId: 'conversation-1',
|
||||
nativeSessionId: 'native-session-1',
|
||||
turnId: 'turn-1',
|
||||
correlationId: 'correlation-1',
|
||||
sequence: 42,
|
||||
nativeEntryCursor: 'native-entry-7',
|
||||
occurredAt: '2026-08-11T12:00:00.000Z',
|
||||
harnessId: 'pi',
|
||||
selection,
|
||||
event: toolStarted,
|
||||
};
|
||||
|
||||
expect(events.map(describeEvent)).toEqual([
|
||||
'session.started',
|
||||
'session.state',
|
||||
'session.identity_changed',
|
||||
'turn.started',
|
||||
'text.delta',
|
||||
'thinking.delta',
|
||||
'tool.started',
|
||||
'tool.updated',
|
||||
'tool.finished',
|
||||
'interaction.required',
|
||||
'usage.updated',
|
||||
'turn.completed',
|
||||
'error',
|
||||
]);
|
||||
expect(envelope).toMatchObject({
|
||||
conversationId: 'conversation-1',
|
||||
nativeSessionId: 'native-session-1',
|
||||
turnId: 'turn-1',
|
||||
correlationId: 'correlation-1',
|
||||
sequence: 42,
|
||||
nativeEntryCursor: 'native-entry-7',
|
||||
harnessId: 'pi',
|
||||
selection,
|
||||
event: toolStarted,
|
||||
});
|
||||
});
|
||||
|
||||
it('models the complete one-response interaction lifecycle', (): void => {
|
||||
const states = [
|
||||
'pending',
|
||||
'responded',
|
||||
'cancelled',
|
||||
'expired',
|
||||
] as const satisfies readonly HarnessInteractionState[];
|
||||
|
||||
expect(states).toEqual(['pending', 'responded', 'cancelled', 'expired']);
|
||||
});
|
||||
|
||||
it('preserves the approved adapter and conversation method signatures', (): void => {
|
||||
expectTypeOf<HarnessAdapter['create']>().toEqualTypeOf<
|
||||
(input: CreateHarnessSession) => Promise<HarnessSessionHandle>
|
||||
>();
|
||||
expectTypeOf<HarnessAdapter['resume']>().toEqualTypeOf<
|
||||
(input: ResumeHarnessSession) => Promise<HarnessSessionHandle>
|
||||
>();
|
||||
expectTypeOf<HarnessSessionHandle['prompt']>().toEqualTypeOf<
|
||||
(input: HarnessPrompt & { idempotencyKey: string }) => Promise<HarnessPromptReceipt>
|
||||
>();
|
||||
expectTypeOf<HarnessConversationService['attach']>().toEqualTypeOf<
|
||||
(input: AttachConversation & { afterSequence?: number }) => Promise<ConversationSnapshot>
|
||||
>();
|
||||
expectTypeOf<HarnessConversationService['send']>().toEqualTypeOf<
|
||||
(input: SendHarnessTurn & { idempotencyKey: string }) => Promise<TurnReceipt>
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { HarnessCapability, HarnessEvent, HarnessEventEnvelope } from './events.js';
|
||||
|
||||
/** Server-derived actor and seat authority. Browser input must not supply these values. */
|
||||
export interface HarnessActorContext {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly seatId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Exact harness/provider/model tuple. These concepts must never be merged into one identifier. */
|
||||
export interface HarnessSelection {
|
||||
readonly harnessId: string;
|
||||
readonly providerId: string;
|
||||
readonly modelId: string;
|
||||
}
|
||||
|
||||
export interface HarnessDescriptor {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly capabilities: readonly HarnessCapability[];
|
||||
}
|
||||
|
||||
export type HarnessInputType = 'text' | 'image';
|
||||
export type HarnessAuthState = 'ready' | 'auth_required' | 'unavailable';
|
||||
export type HarnessModelAvailability = 'available' | 'unavailable';
|
||||
|
||||
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||
readonly displayName: string;
|
||||
readonly reasoningCapability: boolean;
|
||||
readonly thinkingLevels?: readonly string[];
|
||||
readonly inputTypes: readonly HarnessInputType[];
|
||||
readonly contextWindow?: number;
|
||||
readonly authState: HarnessAuthState;
|
||||
readonly availability: HarnessModelAvailability;
|
||||
}
|
||||
|
||||
export interface HarnessCatalog {
|
||||
readonly harnessId: string;
|
||||
readonly version: string;
|
||||
readonly fingerprint: string;
|
||||
readonly models: readonly HarnessCatalogEntry[];
|
||||
}
|
||||
|
||||
export interface CreateHarnessSession {
|
||||
readonly context: HarnessActorContext;
|
||||
readonly conversationId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ResumeHarnessSession {
|
||||
readonly context: HarnessActorContext;
|
||||
readonly conversationId: string;
|
||||
readonly nativeSessionId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export type HarnessSessionState = 'starting' | 'idle' | 'busy' | 'evicted' | 'ended' | 'failed';
|
||||
|
||||
export interface HarnessSessionSnapshot {
|
||||
readonly conversationId: string;
|
||||
readonly nativeSessionId: string;
|
||||
/** Absent when the resumable native session has no active process. */
|
||||
readonly processId?: string;
|
||||
readonly seatId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
readonly state: HarnessSessionState;
|
||||
readonly attachedClientIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface AttachClient {
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface HarnessPrompt {
|
||||
readonly turnId: string;
|
||||
readonly correlationId: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export type HarnessTurnState =
|
||||
| 'prepared'
|
||||
| 'dispatching'
|
||||
| 'accepted'
|
||||
| 'streaming'
|
||||
| 'settled'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
| 'interrupted'
|
||||
| 'outcome_unknown';
|
||||
|
||||
/** A successful receipt reports only the selected tuple; no substitute tuple is representable. */
|
||||
export interface HarnessPromptReceipt {
|
||||
readonly conversationId: string;
|
||||
readonly turnId: string;
|
||||
readonly correlationId: string;
|
||||
readonly state: HarnessTurnState;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface HarnessConfirmInteractionResponse {
|
||||
readonly requestId: string;
|
||||
readonly type: 'confirm';
|
||||
readonly accepted: boolean;
|
||||
}
|
||||
|
||||
export interface HarnessSelectInteractionResponse {
|
||||
readonly requestId: string;
|
||||
readonly type: 'select';
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface HarnessInputInteractionResponse {
|
||||
readonly requestId: string;
|
||||
readonly type: 'input';
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface HarnessEditorInteractionResponse {
|
||||
readonly requestId: string;
|
||||
readonly type: 'editor';
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface HarnessCancelInteractionResponse {
|
||||
readonly requestId: string;
|
||||
readonly type: 'cancel';
|
||||
}
|
||||
|
||||
export type HarnessInteractionResponse =
|
||||
| HarnessConfirmInteractionResponse
|
||||
| HarnessSelectInteractionResponse
|
||||
| HarnessInputInteractionResponse
|
||||
| HarnessEditorInteractionResponse
|
||||
| HarnessCancelInteractionResponse;
|
||||
|
||||
export type HarnessCloseReason =
|
||||
| 'client_request'
|
||||
| 'idle_timeout'
|
||||
| 'gateway_shutdown'
|
||||
| 'process_crash'
|
||||
| 'composition_changed'
|
||||
| 'session_ended';
|
||||
|
||||
export interface AttachConversation {
|
||||
readonly context: HarnessActorContext;
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ConversationSnapshot {
|
||||
readonly session: HarnessSessionSnapshot;
|
||||
readonly lastSequence: number;
|
||||
/** Journal rows replayed after the caller's sequence, never best-effort socket history. */
|
||||
readonly replay: readonly HarnessEventEnvelope[];
|
||||
}
|
||||
|
||||
export interface DetachConversation {
|
||||
readonly context: HarnessActorContext;
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface SendHarnessTurn extends HarnessPrompt {
|
||||
readonly context: HarnessActorContext;
|
||||
readonly conversationId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface TurnReceipt extends HarnessPromptReceipt {}
|
||||
|
||||
export interface HarnessAdapter {
|
||||
readonly id: string;
|
||||
describe(context: HarnessActorContext): Promise<HarnessDescriptor>;
|
||||
catalog(context: HarnessActorContext): Promise<HarnessCatalog>;
|
||||
create(input: CreateHarnessSession): Promise<HarnessSessionHandle>;
|
||||
resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle>;
|
||||
}
|
||||
|
||||
export interface HarnessSessionHandle {
|
||||
snapshot(): Promise<HarnessSessionSnapshot>;
|
||||
attach(input: AttachClient): Promise<void>;
|
||||
/** Removes a browser attachment; it does not terminate the process or native session. */
|
||||
detach(clientId: string): Promise<void>;
|
||||
prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt>;
|
||||
setModel(selection: HarnessSelection): Promise<HarnessSelection>;
|
||||
abort(turnId: string): Promise<void>;
|
||||
respondInteraction(input: HarnessInteractionResponse): Promise<void>;
|
||||
events(listener: (event: HarnessEvent) => void): () => void;
|
||||
/** Stops the active process while retaining the resumable native session. */
|
||||
evictProcess(reason: HarnessCloseReason): Promise<void>;
|
||||
/** Explicitly and destructively ends the native session. */
|
||||
endSession(reason: HarnessCloseReason): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HarnessConversationService {
|
||||
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot>;
|
||||
/** Removes only the browser attachment represented by the input. */
|
||||
detach(input: DetachConversation): Promise<void>;
|
||||
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt>;
|
||||
/** Replays persisted Gateway journal rows after the supplied monotonic sequence. */
|
||||
subscribeFrom(conversationId: string, afterSequence: number): AsyncIterable<HarnessEventEnvelope>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HarnessSelection } from './contracts.js';
|
||||
|
||||
export const HARNESS_ERROR_CODES = [
|
||||
'auth_required',
|
||||
'selection_invalid',
|
||||
'catalog_unavailable',
|
||||
'catalog_stale',
|
||||
'model_unavailable',
|
||||
'no_viable_provider',
|
||||
'session_create_failed',
|
||||
'session_not_found',
|
||||
'resume_conflict',
|
||||
'session_busy',
|
||||
'auth_bundle_concurrency_unverified',
|
||||
'adapter_unavailable',
|
||||
'sandbox_unavailable',
|
||||
'rpc_version_unsupported',
|
||||
'rpc_protocol_error',
|
||||
'process_exited',
|
||||
'outcome_unknown',
|
||||
'interaction_unsupported',
|
||||
'aborted',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export type HarnessErrorCode = (typeof HARNESS_ERROR_CODES)[number];
|
||||
|
||||
export interface HarnessErrorDto<Code extends HarnessErrorCode = HarnessErrorCode> {
|
||||
readonly code: Code;
|
||||
/** Safe for browser and operator-facing surfaces. */
|
||||
readonly message: string;
|
||||
readonly retryable: boolean;
|
||||
readonly correlationId: string;
|
||||
/** The requested selection; errors never report a substituted effective selection. */
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
/** Closed discriminated union over every stable harness error code. */
|
||||
export type HarnessError = {
|
||||
readonly [Code in HarnessErrorCode]: HarnessErrorDto<Code>;
|
||||
}[HarnessErrorCode];
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { HarnessError } from './errors.js';
|
||||
import type { HarnessSelection, HarnessSessionState } from './contracts.js';
|
||||
|
||||
export const HARNESS_CAPABILITIES = [
|
||||
'modelSelection',
|
||||
'thinkingLevels',
|
||||
'images',
|
||||
'toolEvents',
|
||||
'extensionUi',
|
||||
'steering',
|
||||
'followUp',
|
||||
'compaction',
|
||||
'persistentResume',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export type HarnessCapability = (typeof HARNESS_CAPABILITIES)[number];
|
||||
|
||||
/** Durable lifecycle states; later persistence enforces one terminal response per request. */
|
||||
export type HarnessInteractionState = 'pending' | 'responded' | 'cancelled' | 'expired';
|
||||
export type HarnessInteractionType = 'confirm' | 'select' | 'input' | 'editor';
|
||||
|
||||
export interface HarnessUsage {
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly totalTokens: number;
|
||||
}
|
||||
|
||||
export type HarnessTurnOutcome =
|
||||
| 'settled'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
| 'interrupted'
|
||||
| 'outcome_unknown';
|
||||
|
||||
export interface HarnessSessionStartedEvent {
|
||||
readonly type: 'session.started';
|
||||
readonly state: HarnessSessionState;
|
||||
}
|
||||
|
||||
export interface HarnessSessionStateEvent {
|
||||
readonly type: 'session.state';
|
||||
readonly state: HarnessSessionState;
|
||||
}
|
||||
|
||||
export interface HarnessSessionIdentityChangedEvent {
|
||||
readonly type: 'session.identity_changed';
|
||||
readonly identityGeneration: number;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface HarnessTurnStartedEvent {
|
||||
readonly type: 'turn.started';
|
||||
}
|
||||
|
||||
export interface HarnessTextDeltaEvent {
|
||||
readonly type: 'text.delta';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface HarnessThinkingDeltaEvent {
|
||||
readonly type: 'thinking.delta';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface HarnessToolStartedEvent {
|
||||
readonly type: 'tool.started';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
}
|
||||
|
||||
export interface HarnessToolUpdatedEvent {
|
||||
readonly type: 'tool.updated';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface HarnessToolFinishedEvent {
|
||||
readonly type: 'tool.finished';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly isError: boolean;
|
||||
}
|
||||
|
||||
export interface HarnessInteractionRequiredEvent {
|
||||
readonly type: 'interaction.required';
|
||||
readonly requestId: string;
|
||||
readonly interactionType: HarnessInteractionType;
|
||||
readonly state: HarnessInteractionState;
|
||||
readonly prompt: string;
|
||||
readonly options?: readonly string[];
|
||||
}
|
||||
|
||||
export interface HarnessUsageUpdatedEvent {
|
||||
readonly type: 'usage.updated';
|
||||
readonly usage: HarnessUsage;
|
||||
}
|
||||
|
||||
export interface HarnessTurnCompletedEvent {
|
||||
readonly type: 'turn.completed';
|
||||
readonly outcome: HarnessTurnOutcome;
|
||||
}
|
||||
|
||||
export interface HarnessErrorEvent {
|
||||
readonly type: 'error';
|
||||
readonly error: HarnessError;
|
||||
}
|
||||
|
||||
export type HarnessEvent =
|
||||
| HarnessSessionStartedEvent
|
||||
| HarnessSessionStateEvent
|
||||
| HarnessSessionIdentityChangedEvent
|
||||
| HarnessTurnStartedEvent
|
||||
| HarnessTextDeltaEvent
|
||||
| HarnessThinkingDeltaEvent
|
||||
| HarnessToolStartedEvent
|
||||
| HarnessToolUpdatedEvent
|
||||
| HarnessToolFinishedEvent
|
||||
| HarnessInteractionRequiredEvent
|
||||
| HarnessUsageUpdatedEvent
|
||||
| HarnessTurnCompletedEvent
|
||||
| HarnessErrorEvent;
|
||||
|
||||
/** Persisted normalized event plus Gateway-owned ordering and native reconciliation metadata. */
|
||||
export interface HarnessEventEnvelope {
|
||||
readonly conversationId: string;
|
||||
readonly nativeSessionId: string;
|
||||
readonly turnId?: string;
|
||||
readonly correlationId: string;
|
||||
/** Monotonic Gateway journal sequence within the conversation. */
|
||||
readonly sequence: number;
|
||||
/** Native session-entry cursor when the harness provides one. */
|
||||
readonly nativeEntryCursor?: string;
|
||||
readonly occurredAt: string;
|
||||
readonly harnessId: string;
|
||||
/** Exact effective selected provider/model tuple; no alternate success selection is exposed. */
|
||||
readonly selection: HarnessSelection;
|
||||
readonly event: HarnessEvent;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './contracts.js';
|
||||
export * from './events.js';
|
||||
export * from './errors.js';
|
||||
@@ -0,0 +1,11 @@
|
||||
export const VERSION = '0.0.0';
|
||||
|
||||
export * from './channel/index.js';
|
||||
export * from './chat/index.js';
|
||||
export * from './agent/index.js';
|
||||
export * from './provider/index.js';
|
||||
export * from './routing/index.js';
|
||||
export * from './commands/index.js';
|
||||
export * from './federation/index.js';
|
||||
export * from './reflection/index.js';
|
||||
export * from './harness/index.js';
|
||||
@@ -0,0 +1,170 @@
|
||||
/** Per-model capability metadata used by the routing engine */
|
||||
export interface ModelCapability {
|
||||
id: string;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
tier: 'cheap' | 'standard' | 'premium' | 'local';
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
capabilities: {
|
||||
tools: boolean;
|
||||
vision: boolean;
|
||||
streaming: boolean;
|
||||
reasoning: boolean;
|
||||
embedding: boolean;
|
||||
};
|
||||
costPer1kInput?: number;
|
||||
costPer1kOutput?: number;
|
||||
}
|
||||
|
||||
/** Known built-in LLM provider identifiers */
|
||||
export type KnownProvider =
|
||||
| 'anthropic'
|
||||
| 'openai'
|
||||
| 'google'
|
||||
| 'ollama'
|
||||
| 'xai'
|
||||
| 'groq'
|
||||
| 'openrouter'
|
||||
| 'zai'
|
||||
| 'mistral';
|
||||
|
||||
/** Provider identifier — known providers or custom string */
|
||||
export type ProviderId = KnownProvider | string;
|
||||
|
||||
/** Describes an available LLM model */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: ProviderId;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Describes an available provider */
|
||||
export interface ProviderInfo {
|
||||
id: ProviderId;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
/** Configuration for a custom (non-built-in) provider */
|
||||
export interface CustomProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IProviderAdapter pattern — M3-001
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Health status of a provider */
|
||||
export type ProviderHealthStatus = 'healthy' | 'degraded' | 'down';
|
||||
|
||||
/** Result of a provider health check */
|
||||
export interface ProviderHealth {
|
||||
status: ProviderHealthStatus;
|
||||
/** Round-trip latency in milliseconds (undefined when provider is down) */
|
||||
latencyMs?: number;
|
||||
/** ISO-8601 timestamp of the check */
|
||||
lastChecked: string;
|
||||
/** Human-readable error message (defined when status is not healthy) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A single message in a completion request */
|
||||
export interface CompletionMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Tool definition for completion requests */
|
||||
export interface CompletionTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Parameters for a completion request */
|
||||
export interface CompletionParams {
|
||||
model: string;
|
||||
messages: CompletionMessage[];
|
||||
tools?: CompletionTool[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
/** Usage statistics for a completion event */
|
||||
export interface CompletionUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
/** A streamed completion event */
|
||||
export type CompletionEvent =
|
||||
| { type: 'text_delta'; content: string }
|
||||
| { type: 'tool_call'; name: string; arguments: string }
|
||||
| { type: 'done'; usage?: CompletionUsage };
|
||||
|
||||
/**
|
||||
* Pluggable provider adapter interface.
|
||||
*
|
||||
* Each LLM provider (Anthropic, OpenAI, Ollama, etc.) implements this interface
|
||||
* to integrate with Mosaic's provider layer. The ProviderService aggregates all
|
||||
* registered adapters and routes requests accordingly.
|
||||
*
|
||||
* Note on createCompletion: this method is part of the interface for future
|
||||
* direct-completion use cases. The current Pi SDK integration routes completions
|
||||
* through the Pi session/ModelRegistry layer rather than calling adapters directly.
|
||||
* Adapters MUST still implement register() and healthCheck() correctly — those are
|
||||
* used by ProviderService today.
|
||||
*/
|
||||
export interface IProviderAdapter {
|
||||
/** Unique provider identifier (e.g. 'anthropic', 'openai', 'ollama') */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Initialize the provider — connect, discover models, register with the
|
||||
* Pi ModelRegistry. Called once at module startup by ProviderService.registerAll().
|
||||
*/
|
||||
register(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Return the list of models this adapter makes available.
|
||||
* Returns an empty array when the provider is not configured.
|
||||
*/
|
||||
listModels(): ModelInfo[];
|
||||
|
||||
/**
|
||||
* Check whether the provider endpoint is reachable and responsive.
|
||||
*/
|
||||
healthCheck(): Promise<ProviderHealth>;
|
||||
|
||||
/**
|
||||
* Stream a completion from the provider.
|
||||
*
|
||||
* Note: Currently reserved for future use. The Pi SDK integration routes
|
||||
* completions through ModelRegistry / AgentSession rather than this method.
|
||||
* Implementations may throw NotImplementedError until M3+ tasks wire this up.
|
||||
*/
|
||||
createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit tests for the reflection.v1 schema + self-report boundary.
|
||||
*
|
||||
* The runtime source of truth is the zod schema set in `reflection.ts`. The
|
||||
* class-validator `ReflectionSelfReportDto` is the NestJS-side boundary type
|
||||
* (exercised under the gateway app's reflect-metadata runtime, mirroring how
|
||||
* `chat.dto.ts` is tested in apps/gateway); here we validate the self-report
|
||||
* input with its zod counterpart, which is what the Stop hook actually uses.
|
||||
*
|
||||
* Coverage:
|
||||
* - REVIEW_SURFACES canonical ordering (the enum both zod + JSON Schema mirror)
|
||||
* - ReflectionV1Schema accepts a fully-populated record
|
||||
* - ReflectionV1Schema accepts a degraded record (self-report fields null)
|
||||
* - ReflectionV1Schema rejects bad schema literal / out-of-range confidence / bad surface
|
||||
* - ReflectionSelfReportSchema accepts valid + empty, rejects bad input
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
REVIEW_SURFACES,
|
||||
ReflectionV1Schema,
|
||||
ReflectionSelfReportSchema,
|
||||
type ReflectionV1,
|
||||
} from '../index.js';
|
||||
|
||||
const baseMechanical = {
|
||||
schema: 'reflection.v1' as const,
|
||||
task_ref: 'stack#544',
|
||||
agent: 'claude',
|
||||
session_id: 'sess-abc',
|
||||
timestamp: '2026-06-16T00:00:00.000Z',
|
||||
repo: 'stack',
|
||||
risk: {
|
||||
needs_review: true,
|
||||
score: 1.0,
|
||||
surface: 'auth' as const,
|
||||
reason: 'auth surface (weight 1) in: src/auth.ts',
|
||||
},
|
||||
files_changed: ['src/auth.ts'],
|
||||
provenance: {
|
||||
source: 'stop-hook' as const,
|
||||
reflection_attempt: 1,
|
||||
degraded: false,
|
||||
reflection_mode: 'solo' as const,
|
||||
},
|
||||
};
|
||||
|
||||
describe('REVIEW_SURFACES', () => {
|
||||
it('keeps the canonical most→least-sensitive ordering', () => {
|
||||
expect(REVIEW_SURFACES).toEqual([
|
||||
'auth',
|
||||
'data',
|
||||
'infra',
|
||||
'build',
|
||||
'ui',
|
||||
'test',
|
||||
'docs',
|
||||
'none',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReflectionV1Schema', () => {
|
||||
it('accepts a fully-populated record', () => {
|
||||
const rec: ReflectionV1 = {
|
||||
...baseMechanical,
|
||||
confidence: 0.7,
|
||||
most_likely_wrong: { surface: 'auth', description: 'token refresh untested' },
|
||||
known_not_in_diff: 'manual QA only on the happy path',
|
||||
};
|
||||
expect(() => ReflectionV1Schema.parse(rec)).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a degraded record with null self-report fields', () => {
|
||||
const rec: ReflectionV1 = {
|
||||
...baseMechanical,
|
||||
confidence: null,
|
||||
most_likely_wrong: null,
|
||||
known_not_in_diff: null,
|
||||
provenance: { ...baseMechanical.provenance, degraded: true },
|
||||
};
|
||||
expect(() => ReflectionV1Schema.parse(rec)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a wrong schema literal', () => {
|
||||
const bad = {
|
||||
...baseMechanical,
|
||||
schema: 'reflection.v2',
|
||||
confidence: null,
|
||||
most_likely_wrong: null,
|
||||
known_not_in_diff: null,
|
||||
};
|
||||
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects out-of-range confidence', () => {
|
||||
const bad = {
|
||||
...baseMechanical,
|
||||
confidence: 1.5,
|
||||
most_likely_wrong: null,
|
||||
known_not_in_diff: null,
|
||||
};
|
||||
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown surface', () => {
|
||||
const bad = {
|
||||
...baseMechanical,
|
||||
risk: { ...baseMechanical.risk, surface: 'network' },
|
||||
confidence: null,
|
||||
most_likely_wrong: null,
|
||||
known_not_in_diff: null,
|
||||
};
|
||||
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReflectionSelfReportSchema', () => {
|
||||
it('accepts a valid self-report', () => {
|
||||
const ok = ReflectionSelfReportSchema.safeParse({
|
||||
confidence: 0.8,
|
||||
most_likely_wrong: {
|
||||
surface: 'data',
|
||||
description: 'migration not run against prod-sized data',
|
||||
},
|
||||
known_not_in_diff: 'rollback path untested',
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty self-report (all optional)', () => {
|
||||
expect(ReflectionSelfReportSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects confidence above 1', () => {
|
||||
expect(ReflectionSelfReportSchema.safeParse({ confidence: 2 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown most_likely_wrong.surface', () => {
|
||||
const res = ReflectionSelfReportSchema.safeParse({
|
||||
most_likely_wrong: { surface: 'network', description: 'x' },
|
||||
});
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Agent reflection (v1) — public barrel.
|
||||
*
|
||||
* reflection.ts — zod schemas (runtime source of truth) + inferred types
|
||||
* reflection.dto.ts — class-validator DTO for the agent self-report input
|
||||
*/
|
||||
|
||||
export {
|
||||
REVIEW_SURFACES,
|
||||
ReviewSurfaceSchema,
|
||||
MostLikelyWrongSchema,
|
||||
ReflectionRiskSchema,
|
||||
ReflectionModeSchema,
|
||||
ReflectionProvenanceSchema,
|
||||
ReflectionSelfReportSchema,
|
||||
ReflectionV1Schema,
|
||||
REFLECTION_SCHEMA_ID,
|
||||
} from './reflection.js';
|
||||
|
||||
export type {
|
||||
ReviewSurface,
|
||||
MostLikelyWrong,
|
||||
ReflectionRisk,
|
||||
ReflectionMode,
|
||||
ReflectionProvenance,
|
||||
ReflectionSelfReport,
|
||||
ReflectionV1,
|
||||
} from './reflection.js';
|
||||
|
||||
export { MostLikelyWrongDto, ReflectionSelfReportDto } from './reflection.dto.js';
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Reflection self-report DTO — class-validator boundary.
|
||||
*
|
||||
* Validates the agent-supplied self-report input (the optional
|
||||
* `$REFLECTION_INPUT` file, default `<repo>/.mosaic/reflection-input.json`)
|
||||
* before it is merged into a `reflection.v1` record. This is the only
|
||||
* externally-authored input on the reflection path, so it gets a DTO per the
|
||||
* Mosaic module-boundary rule.
|
||||
*
|
||||
* Class-validator only (no class-transformer `@Type`) — matching `chat.dto.ts`
|
||||
* — so the module is safe to import without a `reflect-metadata` shim. Deep
|
||||
* nested validation of `most_likely_wrong` is owned by the zod
|
||||
* `ReflectionSelfReportSchema` in `reflection.ts`, which is what the Stop hook
|
||||
* actually enforces at runtime.
|
||||
*/
|
||||
|
||||
import {
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { REVIEW_SURFACES } from './reflection.js';
|
||||
|
||||
/** Shape of `most_likely_wrong`; validated structurally by zod at runtime. */
|
||||
export class MostLikelyWrongDto {
|
||||
@IsIn(REVIEW_SURFACES as unknown as string[])
|
||||
surface!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(4_000)
|
||||
description!: string;
|
||||
}
|
||||
|
||||
export class ReflectionSelfReportDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
confidence?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
most_likely_wrong?: MostLikelyWrongDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8_000)
|
||||
known_not_in_diff?: string;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Agent reflection (v1) — wire schema.
|
||||
*
|
||||
* Runtime source of truth for the `reflection.v1` sidecar emitted at end-of-run
|
||||
* by the Stop hook (design §10 step 1). The JSON Schema artifact at
|
||||
* `@mosaicstack/macp` `src/schemas/reflection.v1.schema.json` is the documented
|
||||
* contract; this zod schema is the executable one and MUST agree with it.
|
||||
*
|
||||
* Field provenance:
|
||||
* - MECHANICAL (risk, files_changed, ids, provenance): written by the hook.
|
||||
* - SELF-REPORTED (confidence, most_likely_wrong, known_not_in_diff): merged
|
||||
* from an optional agent-supplied input; null when absent.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Review surfaces, ordered most- to least-sensitive. Mirrors macp risk-floor. */
|
||||
export const REVIEW_SURFACES = [
|
||||
'auth',
|
||||
'data',
|
||||
'infra',
|
||||
'build',
|
||||
'ui',
|
||||
'test',
|
||||
'docs',
|
||||
'none',
|
||||
] as const;
|
||||
|
||||
export const ReviewSurfaceSchema = z.enum(REVIEW_SURFACES);
|
||||
export type ReviewSurface = z.infer<typeof ReviewSurfaceSchema>;
|
||||
|
||||
/** SELF-REPORTED: the single most-likely way the work is wrong. */
|
||||
export const MostLikelyWrongSchema = z.object({
|
||||
surface: ReviewSurfaceSchema,
|
||||
description: z.string(),
|
||||
});
|
||||
export type MostLikelyWrong = z.infer<typeof MostLikelyWrongSchema>;
|
||||
|
||||
/** MECHANICAL: output of the diff risk-floor (see `@mosaicstack/macp`). */
|
||||
export const ReflectionRiskSchema = z.object({
|
||||
needs_review: z.boolean(),
|
||||
score: z.number().min(0).max(1),
|
||||
surface: ReviewSurfaceSchema,
|
||||
reason: z.string(),
|
||||
});
|
||||
export type ReflectionRisk = z.infer<typeof ReflectionRiskSchema>;
|
||||
|
||||
export const ReflectionModeSchema = z.enum(['off', 'solo', 'orchestrated']);
|
||||
export type ReflectionMode = z.infer<typeof ReflectionModeSchema>;
|
||||
|
||||
export const ReflectionProvenanceSchema = z.object({
|
||||
source: z.literal('stop-hook'),
|
||||
reflection_attempt: z.number().int().min(1),
|
||||
degraded: z.boolean(),
|
||||
reflection_mode: ReflectionModeSchema,
|
||||
});
|
||||
export type ReflectionProvenance = z.infer<typeof ReflectionProvenanceSchema>;
|
||||
|
||||
/**
|
||||
* The self-reported half of a reflection. Supplied by the agent out-of-band
|
||||
* (e.g. `<repo>/.mosaic/reflection-input.json`) and merged by the hook. All
|
||||
* fields optional; missing fields become `null` in the assembled record.
|
||||
*/
|
||||
export const ReflectionSelfReportSchema = z.object({
|
||||
confidence: z.number().min(0).max(1).nullable().optional(),
|
||||
most_likely_wrong: MostLikelyWrongSchema.nullable().optional(),
|
||||
known_not_in_diff: z.string().nullable().optional(),
|
||||
});
|
||||
export type ReflectionSelfReport = z.infer<typeof ReflectionSelfReportSchema>;
|
||||
|
||||
/** The full assembled `reflection.v1` sidecar. */
|
||||
export const ReflectionV1Schema = z.object({
|
||||
schema: z.literal('reflection.v1'),
|
||||
task_ref: z.string(),
|
||||
agent: z.string(),
|
||||
session_id: z.string(),
|
||||
timestamp: z.string(),
|
||||
repo: z.string(),
|
||||
confidence: z.number().min(0).max(1).nullable(),
|
||||
most_likely_wrong: MostLikelyWrongSchema.nullable(),
|
||||
known_not_in_diff: z.string().nullable(),
|
||||
risk: ReflectionRiskSchema,
|
||||
files_changed: z.array(z.string()),
|
||||
provenance: ReflectionProvenanceSchema,
|
||||
});
|
||||
export type ReflectionV1 = z.infer<typeof ReflectionV1Schema>;
|
||||
|
||||
export const REFLECTION_SCHEMA_ID = 'reflection.v1' as const;
|
||||
@@ -0,0 +1,134 @@
|
||||
// ─── Legacy simple-routing types (kept for backward compatibility) ────────────
|
||||
|
||||
/** Result of a simple scoring-based routing decision */
|
||||
export interface RoutingResult {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
score: number;
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
/** Routing criteria for score-based model selection */
|
||||
export interface RoutingCriteria {
|
||||
taskType?: TaskType;
|
||||
costTier?: CostTier;
|
||||
requireReasoning?: boolean;
|
||||
requireImageInput?: boolean;
|
||||
minContextWindow?: number;
|
||||
preferredProvider?: string;
|
||||
preferredModel?: string;
|
||||
}
|
||||
|
||||
// ─── Classification primitives (M4-002) ──────────────────────────────────────
|
||||
|
||||
/** Category of work the agent is being asked to perform */
|
||||
export type TaskType =
|
||||
| 'chat'
|
||||
| 'coding'
|
||||
| 'research'
|
||||
| 'summarization'
|
||||
| 'conversation'
|
||||
| 'analysis'
|
||||
| 'creative'
|
||||
| 'general';
|
||||
|
||||
/** Estimated complexity of the task, used to bias toward cheaper or more capable models */
|
||||
export type Complexity = 'simple' | 'moderate' | 'complex';
|
||||
|
||||
/** Primary knowledge domain of the task */
|
||||
export type Domain = 'frontend' | 'backend' | 'devops' | 'docs' | 'general';
|
||||
|
||||
/**
|
||||
* Cost tier for model selection.
|
||||
* `local` targets self-hosted/on-premises models.
|
||||
*/
|
||||
export type CostTier = 'cheap' | 'standard' | 'premium' | 'local';
|
||||
|
||||
/** Special model capability required by the task */
|
||||
export type Capability = 'tools' | 'vision' | 'long-context' | 'reasoning' | 'embedding';
|
||||
|
||||
// ─── Condition types (M4-002) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single predicate that must be satisfied for a routing rule to match.
|
||||
*
|
||||
* - `eq` — scalar equality: `field === value`
|
||||
* - `in` — set membership: `value` (array) contains `field`
|
||||
* - `includes` — array containment: `field` (array) includes `value`
|
||||
*/
|
||||
export interface RoutingCondition {
|
||||
/** The task-classification field to test */
|
||||
field: 'taskType' | 'complexity' | 'domain' | 'costTier' | 'requiredCapabilities';
|
||||
/** Comparison operator */
|
||||
operator: 'eq' | 'in' | 'includes';
|
||||
/** Expected value or set of values */
|
||||
value: string | string[];
|
||||
}
|
||||
|
||||
// ─── Action types (M4-003) ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The routing action to execute when all conditions in a rule are satisfied.
|
||||
*/
|
||||
export interface RoutingAction {
|
||||
/** LLM provider identifier, e.g. `'anthropic'`, `'openai'`, `'ollama'` */
|
||||
provider: string;
|
||||
/** Model identifier, e.g. `'claude-opus-4-6'`, `'gpt-4o'` */
|
||||
model: string;
|
||||
/** Optional: use a specific pre-configured agent config from the agent registry */
|
||||
agentConfigId?: string;
|
||||
/** Optional: override the agent's default system prompt for this route */
|
||||
systemPromptOverride?: string;
|
||||
/** Optional: restrict the tool set available to the agent for this route */
|
||||
toolAllowlist?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full routing rule as stored in the database and used at runtime.
|
||||
*/
|
||||
export interface RoutingRule {
|
||||
/** UUID primary key */
|
||||
id: string;
|
||||
/** Human-readable rule name */
|
||||
name: string;
|
||||
/** Lower number = evaluated first; unique per scope */
|
||||
priority: number;
|
||||
/** `'system'` rules apply globally; `'user'` rules override for a specific user */
|
||||
scope: 'system' | 'user';
|
||||
/** Present only for `'user'`-scoped rules */
|
||||
userId?: string;
|
||||
/** All conditions must match for the rule to fire */
|
||||
conditions: RoutingCondition[];
|
||||
/** Action to take when all conditions are met */
|
||||
action: RoutingAction;
|
||||
/** Whether this rule is active */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured representation of what an agent has been asked to do,
|
||||
* produced by the task classifier and consumed by the routing engine.
|
||||
*/
|
||||
export interface TaskClassification {
|
||||
taskType: TaskType;
|
||||
complexity: Complexity;
|
||||
domain: Domain;
|
||||
requiredCapabilities: Capability[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Output of the routing engine — which model to use and why.
|
||||
*/
|
||||
export interface RoutingDecision {
|
||||
/** LLM provider identifier */
|
||||
provider: string;
|
||||
/** Model identifier */
|
||||
model: string;
|
||||
/** Optional agent config to apply */
|
||||
agentConfigId?: string;
|
||||
/** Name of the rule that matched, for observability */
|
||||
ruleName: string;
|
||||
/** Human-readable explanation of why this rule was selected */
|
||||
reason: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user