diff --git a/.env.example b/.env.example index 47e1d6c..380c3d1 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ SESSION_POLL_TIMEOUT_MS=60000 # HTTP timeout for proxying a UI cancel to the initiator agent's cancelCallback. CANCEL_CALLBACK_TIMEOUT_MS=5000 +# ── Session Discovery ───────────────────────────── +# When enabled, subscribes to WatchSessions on the runtime and auto-creates +# run records for sessions started by external launchers (not via POST /runs). +SESSION_DISCOVERY_ENABLED=true + # ── Circuit Breaker ────────────────────────────── RUNTIME_CIRCUIT_BREAKER_THRESHOLD=5 RUNTIME_CIRCUIT_BREAKER_RESET_MS=30000 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee57651..ebf8a1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -77,6 +77,17 @@ Runtime gRPC stream → StreamHubService.publishEvent (SSE → live UI subscribers) ``` +## Session Discovery (WatchSessions) + +When `SESSION_DISCOVERY_ENABLED=true` (default), the `SessionDiscoveryService` subscribes +to the runtime's `WatchSessions` gRPC stream and auto-creates run records for sessions +started by external launchers (not via `POST /runs`). For each `created` event, it creates +a run, binds the session, subscribes the observer stream, and begins projecting events. +Terminal events (`resolved`, `expired`) finalize the auto-discovered run. + +This enables the control-plane to observe and project any session the runtime hosts, even +if the launching service doesn't use the control-plane's `POST /runs` endpoint. + ## Message / Signal / Context — removed (direct-agent-auth CP-5/6/7) The `POST /runs/:id/{messages,signal,context}` endpoints were removed 2026-04-15 and now diff --git a/src/app.module.ts b/src/app.module.ts index 2c15ab0..7dadd13 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -49,6 +49,7 @@ import { RunExecutorService } from './runs/run-executor.service'; import { RunManagerService } from './runs/run-manager.service'; import { RunRecoveryService } from './runs/run-recovery.service'; import { StreamConsumerService } from './runs/stream-consumer.service'; +import { SessionDiscoveryService } from './runs/session-discovery.service'; import { WebhookController } from './controllers/webhook.controller'; import { WebhookDeliveryRepository } from './webhooks/webhook-delivery.repository'; import { WebhookRepository } from './webhooks/webhook.repository'; @@ -63,13 +64,27 @@ import { WebhookService } from './webhooks/webhook.service'; ThrottlerModule.forRootAsync({ imports: [ConfigModule], inject: [AppConfigService], - useFactory: (config: AppConfigService) => [{ - ttl: config.throttleTtlMs, - limit: config.throttleLimit, - }], + useFactory: (config: AppConfigService) => [ + { + ttl: config.throttleTtlMs, + limit: config.throttleLimit + } + ] }) ], - controllers: [RunsController, RunInsightsController, RuntimeController, ObservabilityController, HealthController, MetricsController, AdminController, AuditController, WebhookController, DashboardController, EventsController], + controllers: [ + RunsController, + RunInsightsController, + RuntimeController, + ObservabilityController, + HealthController, + MetricsController, + AdminController, + AuditController, + WebhookController, + DashboardController, + EventsController + ], providers: [ { provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: ThrottleByUserGuard }, @@ -95,7 +110,7 @@ import { WebhookService } from './webhooks/webhook.service'; } return new MemoryStreamHubStrategy(); }, - inject: [AppConfigService], + inject: [AppConfigService] }, StreamHubService, EventNormalizerService, @@ -107,6 +122,7 @@ import { WebhookService } from './webhooks/webhook.service'; ReplayService, RunManagerService, StreamConsumerService, + SessionDiscoveryService, RunExecutorService, RunRecoveryService, RunInsightsService, @@ -119,8 +135,6 @@ import { WebhookService } from './webhooks/webhook.service'; }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer - .apply(CorrelationIdMiddleware, RequestLoggerMiddleware) - .forRoutes('*'); + consumer.apply(CorrelationIdMiddleware, RequestLoggerMiddleware).forRoutes('*'); } } diff --git a/src/artifacts/artifact.service.spec.ts b/src/artifacts/artifact.service.spec.ts index e4a54f9..5d1ae45 100644 --- a/src/artifacts/artifact.service.spec.ts +++ b/src/artifacts/artifact.service.spec.ts @@ -8,7 +8,7 @@ describe('ArtifactService', () => { beforeEach(() => { mockRepo = { create: jest.fn(), - listByRunId: jest.fn(), + listByRunId: jest.fn() }; service = new ArtifactService(mockRepo as unknown as ArtifactRepository); }); @@ -19,12 +19,12 @@ describe('ArtifactService', () => { runId: 'run-1', kind: 'json' as const, label: 'test-artifact', - uri: 'https://example.com/artifact.json', + uri: 'https://example.com/artifact.json' }; const expected = { ...input, id: 'a1a1a1a1-b2b2-c3c3-d4d4-e5e5e5e5e5e5', - createdAt: '2026-04-07T00:00:00.000Z', + createdAt: '2026-04-07T00:00:00.000Z' }; mockRepo.create.mockResolvedValue(expected); @@ -40,12 +40,12 @@ describe('ArtifactService', () => { runId: 'run-2', kind: 'report' as const, label: 'inline-report', - inline: { summary: 'all good', score: 42 }, + inline: { summary: 'all good', score: 42 } }; const expected = { ...input, id: 'b1b1b1b1-c2c2-d3d3-e4e4-f5f5f5f5f5f5', - createdAt: '2026-04-07T00:00:01.000Z', + createdAt: '2026-04-07T00:00:01.000Z' }; mockRepo.create.mockResolvedValue(expected); @@ -58,17 +58,33 @@ describe('ArtifactService', () => { it('propagates repository errors', async () => { mockRepo.create.mockRejectedValue(new Error('db write failed')); - await expect( - service.register({ runId: 'run-1', kind: 'log' as const, label: 'x' }), - ).rejects.toThrow('db write failed'); + await expect(service.register({ runId: 'run-1', kind: 'log' as const, label: 'x' })).rejects.toThrow( + 'db write failed' + ); }); }); describe('list', () => { it('delegates to repository.listByRunId and returns the result', async () => { const artifacts = [ - { id: 'a1', runId: 'run-1', kind: 'json', label: 'first', uri: null, inline: null, createdAt: '2026-01-01T00:00:00Z' }, - { id: 'a2', runId: 'run-1', kind: 'trace', label: 'second', uri: null, inline: null, createdAt: '2026-01-01T00:01:00Z' }, + { + id: 'a1', + runId: 'run-1', + kind: 'json', + label: 'first', + uri: null, + inline: null, + createdAt: '2026-01-01T00:00:00Z' + }, + { + id: 'a2', + runId: 'run-1', + kind: 'trace', + label: 'second', + uri: null, + inline: null, + createdAt: '2026-01-01T00:01:00Z' + } ]; mockRepo.listByRunId.mockResolvedValue(artifacts); diff --git a/src/audit/audit.service.spec.ts b/src/audit/audit.service.spec.ts index 015a853..debac0b 100644 --- a/src/audit/audit.service.spec.ts +++ b/src/audit/audit.service.spec.ts @@ -20,7 +20,7 @@ describe('AuditService', () => { resource: 'run', resourceId: 'run-123', details: { mode: 'decision' }, - requestId: 'req-abc', + requestId: 'req-abc' }; beforeEach(() => { @@ -37,8 +37,8 @@ describe('AuditService', () => { mockDatabase = { db: { insert: mockInsert, - select: mockSelect, - }, + select: mockSelect + } }; service = new AuditService(mockDatabase as unknown as DatabaseService); }); @@ -67,7 +67,7 @@ describe('AuditService', () => { actor: 'system', actorType: 'system', action: 'circuit_breaker.reset', - resource: 'circuit_breaker', + resource: 'circuit_breaker' }; await service.record(entry); @@ -92,9 +92,7 @@ describe('AuditService', () => { describe('list', () => { it('returns data and total from the database', async () => { - const fakeRows = [ - { id: 'a1', actor: 'user-1', action: 'run.create', createdAt: '2026-01-01T00:00:00Z' }, - ]; + const fakeRows = [{ id: 'a1', actor: 'user-1', action: 'run.create', createdAt: '2026-01-01T00:00:00Z' }]; // First select call returns data rows mockOffset.mockResolvedValueOnce(fakeRows); // Second select call (count) — needs its own chain @@ -102,9 +100,7 @@ describe('AuditService', () => { const countFrom = jest.fn().mockReturnValue({ where: countWhere }); // Promise.all calls select twice: once for data, once for count - mockSelect - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: countFrom }); + mockSelect.mockReturnValueOnce({ from: mockFrom }).mockReturnValueOnce({ from: countFrom }); const result = await service.list({}); @@ -117,9 +113,7 @@ describe('AuditService', () => { const countWhere = jest.fn().mockResolvedValue([]); const countFrom = jest.fn().mockReturnValue({ where: countWhere }); - mockSelect - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: countFrom }); + mockSelect.mockReturnValueOnce({ from: mockFrom }).mockReturnValueOnce({ from: countFrom }); const result = await service.list({}); @@ -132,9 +126,7 @@ describe('AuditService', () => { const countWhere = jest.fn().mockResolvedValue([{ count: 0 }]); const countFrom = jest.fn().mockReturnValue({ where: countWhere }); - mockSelect - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: countFrom }); + mockSelect.mockReturnValueOnce({ from: mockFrom }).mockReturnValueOnce({ from: countFrom }); await service.list({}); @@ -147,9 +139,7 @@ describe('AuditService', () => { const countWhere = jest.fn().mockResolvedValue([{ count: 0 }]); const countFrom = jest.fn().mockReturnValue({ where: countWhere }); - mockSelect - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: countFrom }); + mockSelect.mockReturnValueOnce({ from: mockFrom }).mockReturnValueOnce({ from: countFrom }); await service.list({ limit: 10, offset: 20 }); @@ -162,9 +152,7 @@ describe('AuditService', () => { const countWhere = jest.fn().mockResolvedValue([{ count: 0 }]); const countFrom = jest.fn().mockReturnValue({ where: countWhere }); - mockSelect - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: countFrom }); + mockSelect.mockReturnValueOnce({ from: mockFrom }).mockReturnValueOnce({ from: countFrom }); await service.list({ actor: 'user-1', action: 'run.create' }); diff --git a/src/auth/auth.guard.spec.ts b/src/auth/auth.guard.spec.ts index 0048879..570708a 100644 --- a/src/auth/auth.guard.spec.ts +++ b/src/auth/auth.guard.spec.ts @@ -17,31 +17,28 @@ describe('AuthGuard', () => { switchToHttp: () => ({ getRequest: () => request, getResponse: jest.fn(), - getNext: jest.fn(), + getNext: jest.fn() }), getArgs: jest.fn(), getArgByIndex: jest.fn(), switchToRpc: jest.fn(), switchToWs: jest.fn(), - getType: jest.fn(), + getType: jest.fn() } as unknown as ExecutionContext; } beforeEach(() => { mockReflector = { - getAllAndOverride: jest.fn().mockReturnValue(false), + getAllAndOverride: jest.fn().mockReturnValue(false) }; mockConfig = { - authApiKeys: ['test-api-key-12345678'], + authApiKeys: ['test-api-key-12345678'] }; mockRequest = { - headers: {}, + headers: {} }; - guard = new AuthGuard( - mockReflector as unknown as Reflector, - mockConfig as AppConfigService, - ); + guard = new AuthGuard(mockReflector as unknown as Reflector, mockConfig as AppConfigService); }); // ========================================================================= @@ -56,7 +53,7 @@ describe('AuthGuard', () => { expect(result).toBe(true); expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith(IS_PUBLIC_KEY, [ context.getHandler(), - context.getClass(), + context.getClass() ]); }); diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts index c9be6b7..24f227b 100644 --- a/src/auth/auth.guard.ts +++ b/src/auth/auth.guard.ts @@ -27,9 +27,7 @@ export class AuthGuard implements CanActivate { } // Support both "Bearer " and raw API key - const token = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; if (!token) { throw new UnauthorizedException('Empty authorization token'); diff --git a/src/auth/throttle-by-user.guard.spec.ts b/src/auth/throttle-by-user.guard.spec.ts index c201af8..e73c70e 100644 --- a/src/auth/throttle-by-user.guard.spec.ts +++ b/src/auth/throttle-by-user.guard.spec.ts @@ -73,8 +73,8 @@ describe('ThrottleByUserGuard', () => { const context = { switchToHttp: () => ({ getRequest: () => mockReq, - getResponse: () => mockRes, - }), + getResponse: () => mockRes + }) } as unknown as ExecutionContext; const result = (guard as any).getRequestResponse(context); diff --git a/src/config/app-config.service.ts b/src/config/app-config.service.ts index 1d2de7d..157b45f 100644 --- a/src/config/app-config.service.ts +++ b/src/config/app-config.service.ts @@ -16,7 +16,10 @@ function readNumber(name: string, defaultValue: number): number { function readStringList(name: string): string[] { const raw = process.env[name]; if (!raw) return []; - return raw.split(',').map((s) => s.trim()).filter(Boolean); + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); } @Injectable() @@ -39,8 +42,7 @@ export class AppConfigService implements OnModuleInit { readonly port = readNumber('PORT', 3001); readonly host = process.env.HOST ?? '0.0.0.0'; readonly corsOrigin = process.env.CORS_ORIGIN ?? 'http://localhost:3000'; - readonly databaseUrl = - process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/macp_control_plane'; + readonly databaseUrl = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/macp_control_plane'; // Auth readonly authApiKeys = readStringList('AUTH_API_KEYS'); @@ -85,6 +87,7 @@ export class AppConfigService implements OnModuleInit { readonly replayMaxDelayMs = readNumber('REPLAY_MAX_DELAY_MS', 2000); readonly replayBatchSize = readNumber('REPLAY_BATCH_SIZE', 500); readonly runRecoveryEnabled = readBoolean('RUN_RECOVERY_ENABLED', true); + readonly sessionDiscoveryEnabled = readBoolean('SESSION_DISCOVERY_ENABLED', true); readonly dbPoolMax = readNumber('DB_POOL_MAX', 20); readonly dbPoolIdleTimeout = readNumber('DB_POOL_IDLE_TIMEOUT', 30000); @@ -116,9 +119,7 @@ export class AppConfigService implements OnModuleInit { // 1.2: Fail-fast if bearer token missing in production with dev header enabled if (!this.runtimeBearerToken && this.runtimeUseDevHeader) { - throw new Error( - 'RUNTIME_BEARER_TOKEN must be set in production when RUNTIME_USE_DEV_HEADER is enabled' - ); + throw new Error('RUNTIME_BEARER_TOKEN must be set in production when RUNTIME_USE_DEV_HEADER is enabled'); } // Warn (don't fail) when the control-plane has no runtime identity configured — @@ -131,16 +132,12 @@ export class AppConfigService implements OnModuleInit { // 1.2: Fail-fast if TLS is off and insecure is not explicitly allowed if (!this.runtimeTls && !this.runtimeAllowInsecure) { - throw new Error( - 'RUNTIME_TLS must be true in production, or set RUNTIME_ALLOW_INSECURE=true to override' - ); + throw new Error('RUNTIME_TLS must be true in production, or set RUNTIME_ALLOW_INSECURE=true to override'); } // 1.2: Warn if OTEL enabled without exporter endpoint if (this.otelEnabled && !this.otelExporterOtlpEndpoint) { - this.logger.warn( - 'OTEL_ENABLED is true but OTEL_EXPORTER_OTLP_ENDPOINT is not set — traces will be discarded' - ); + this.logger.warn('OTEL_ENABLED is true but OTEL_EXPORTER_OTLP_ENDPOINT is not set — traces will be discarded'); } // Warn if using memory StreamHub in production (SSE events won't sync across instances) @@ -152,23 +149,17 @@ export class AppConfigService implements OnModuleInit { // Fail-fast if no API keys configured in production (auth silently disabled) if (this.authApiKeys.length === 0) { - throw new Error( - 'AUTH_API_KEYS must be set in production. Empty value disables authentication.' - ); + throw new Error('AUTH_API_KEYS must be set in production. Empty value disables authentication.'); } // Guard against misconfigured retention TTL if (this.dataRetentionEnabled && this.dataRetentionTtlDays < 1) { - throw new Error( - 'DATA_RETENTION_TTL_DAYS must be >= 1 when retention is enabled' - ); + throw new Error('DATA_RETENTION_TTL_DAYS must be >= 1 when retention is enabled'); } // Guard against connection pool starvation if (this.dbPoolMax < 2) { - throw new Error( - 'DB_POOL_MAX must be >= 2 to avoid connection pool starvation' - ); + throw new Error('DB_POOL_MAX must be >= 2 to avoid connection pool starvation'); } // Warn about aggressive gRPC timeout diff --git a/src/contracts/control-plane.spec.ts b/src/contracts/control-plane.spec.ts index 1891e05..2c22521 100644 --- a/src/contracts/control-plane.spec.ts +++ b/src/contracts/control-plane.spec.ts @@ -28,7 +28,7 @@ describe('CANONICAL_EVENT_TYPES (§3 contract stability)', () => { 'policy.resolved', 'policy.commitment.evaluated', 'policy.denied', - 'llm.call.completed', + 'llm.call.completed' ]; expect([...CANONICAL_EVENT_TYPES].sort()).toEqual(expected.sort()); }); diff --git a/src/contracts/control-plane.ts b/src/contracts/control-plane.ts index cc606d4..e89345c 100644 --- a/src/contracts/control-plane.ts +++ b/src/contracts/control-plane.ts @@ -78,15 +78,7 @@ export interface RunDescriptor { }; } - -export type RunStatus = - | 'queued' - | 'starting' - | 'binding_session' - | 'running' - | 'completed' - | 'failed' - | 'cancelled'; +export type RunStatus = 'queued' | 'starting' | 'binding_session' | 'running' | 'completed' | 'failed' | 'cancelled'; export type SessionState = | 'SESSION_STATE_UNSPECIFIED' @@ -147,7 +139,7 @@ export const CANONICAL_EVENT_TYPES = [ 'llm.call.completed' ] as const; -export type CanonicalEventType = typeof CANONICAL_EVENT_TYPES[number]; +export type CanonicalEventType = (typeof CANONICAL_EVENT_TYPES)[number]; export interface CanonicalEvent { id: string; @@ -202,6 +194,8 @@ export interface RunSummaryProjection { endedAt?: string; traceId?: string; modeName?: string; + contextId?: string; + extensionKeys?: string[]; } export interface ParticipantProjection { diff --git a/src/contracts/runtime.ts b/src/contracts/runtime.ts index 7fd0a75..9d7d1a7 100644 --- a/src/contracts/runtime.ts +++ b/src/contracts/runtime.ts @@ -1,8 +1,4 @@ -import { - CanonicalEvent, - RunDescriptor, - SessionState -} from './control-plane'; +import { CanonicalEvent, RunDescriptor, SessionState } from './control-plane'; export interface RuntimeCredentials { metadata: Record; @@ -194,6 +190,14 @@ export interface RuntimeCapabilities { * the runtime (RFC-MACP-0004 §4). The provider's job is to initialize, observe, inspect, * and (conditionally) cancel sessions. See direct-agent-auth.md §Invariants. */ +export type SessionLifecycleEventType = 'created' | 'resolved' | 'expired'; + +export interface SessionLifecycleEvent { + eventType: SessionLifecycleEventType; + session: RuntimeSessionSnapshot; + observedAtUnixMs: number; +} + export interface RuntimeProvider { readonly kind: string; @@ -204,12 +208,6 @@ export interface RuntimeProvider { getSession(req: RuntimeGetSessionRequest): Promise; - /** - * Policy-delegated cancellation (direct-agent-auth §Cancellation design — Option B). - * Only called when the run's metadata records `cancellationDelegated: true`. Default - * cancellation flow (Option A) proxies through the initiator agent's callback and - * does not invoke this method. - */ cancelSession(req: RuntimeCancelSessionRequest): Promise; getManifest(): Promise; @@ -217,6 +215,10 @@ export interface RuntimeProvider { listRoots(): Promise; health(): Promise; + // Session lifecycle observation + listSessions(): Promise; + watchSessions(): AsyncIterable; + // Governance policy lifecycle (RFC-MACP-0012) registerPolicy(req: RuntimeRegisterPolicyRequest): Promise; unregisterPolicy(req: RuntimeUnregisterPolicyRequest): Promise; diff --git a/src/controllers/admin.controller.spec.ts b/src/controllers/admin.controller.spec.ts index 6c8d6e2..f129903 100644 --- a/src/controllers/admin.controller.spec.ts +++ b/src/controllers/admin.controller.spec.ts @@ -13,14 +13,12 @@ describe('AdminController', () => { mockRustRuntime = { resetCircuitBreaker: jest.fn(), getCircuitBreakerState: jest.fn().mockReturnValue('CLOSED'), - getCircuitBreakerHistory: jest.fn().mockReturnValue([ - { state: 'CLOSED', enteredAt: '2026-04-13T00:00:00Z', reason: 'initial' }, - ]), + getCircuitBreakerHistory: jest + .fn() + .mockReturnValue([{ state: 'CLOSED', enteredAt: '2026-04-13T00:00:00Z', reason: 'initial' }]) }; - controller = new AdminController( - mockRustRuntime as unknown as RustRuntimeProvider, - ); + controller = new AdminController(mockRustRuntime as unknown as RustRuntimeProvider); }); describe('resetCircuitBreaker', () => { @@ -44,7 +42,7 @@ describe('AdminController', () => { expect(mockRustRuntime.getCircuitBreakerHistory).toHaveBeenCalledWith(undefined); expect(result).toEqual({ state: 'CLOSED', - history: [{ state: 'CLOSED', enteredAt: '2026-04-13T00:00:00Z', reason: 'initial' }], + history: [{ state: 'CLOSED', enteredAt: '2026-04-13T00:00:00Z', reason: 'initial' }] }); }); diff --git a/src/controllers/audit.controller.spec.ts b/src/controllers/audit.controller.spec.ts index cce7a15..118b942 100644 --- a/src/controllers/audit.controller.spec.ts +++ b/src/controllers/audit.controller.spec.ts @@ -10,12 +10,10 @@ describe('AuditController', () => { beforeEach(() => { mockAuditService = { - list: jest.fn().mockResolvedValue([]), + list: jest.fn().mockResolvedValue([]) }; - controller = new AuditController( - mockAuditService as unknown as AuditService, - ); + controller = new AuditController(mockAuditService as unknown as AuditService); }); describe('listAuditLogs', () => { @@ -28,7 +26,7 @@ describe('AuditController', () => { createdAfter: '2025-01-01', createdBefore: '2025-12-31', limit: 25, - offset: 10, + offset: 10 }; await controller.listAuditLogs(query); @@ -41,7 +39,7 @@ describe('AuditController', () => { createdAfter: '2025-01-01', createdBefore: '2025-12-31', limit: 25, - offset: 10, + offset: 10 }); }); @@ -53,8 +51,8 @@ describe('AuditController', () => { expect(mockAuditService.list).toHaveBeenCalledWith( expect.objectContaining({ limit: 50, - offset: 0, - }), + offset: 0 + }) ); }); @@ -62,7 +60,7 @@ describe('AuditController', () => { const query: ListAuditQueryDto = { actor: 'admin', action: 'run.delete', - resource: 'run', + resource: 'run' }; await controller.listAuditLogs(query); @@ -71,8 +69,8 @@ describe('AuditController', () => { expect.objectContaining({ actor: 'admin', action: 'run.delete', - resource: 'run', - }), + resource: 'run' + }) ); }); }); diff --git a/src/controllers/audit.controller.ts b/src/controllers/audit.controller.ts index e1b56a8..01784f8 100644 --- a/src/controllers/audit.controller.ts +++ b/src/controllers/audit.controller.ts @@ -10,9 +10,7 @@ export class AuditController { @Get() @ApiOperation({ summary: 'List audit log entries with optional filtering.' }) - async listAuditLogs( - @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListAuditQueryDto - ) { + async listAuditLogs(@Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListAuditQueryDto) { return this.auditService.list({ actor: query.actor, action: query.action, diff --git a/src/controllers/dashboard.controller.spec.ts b/src/controllers/dashboard.controller.spec.ts index dfe2e7b..29f236e 100644 --- a/src/controllers/dashboard.controller.spec.ts +++ b/src/controllers/dashboard.controller.spec.ts @@ -10,17 +10,21 @@ describe('DashboardController', () => { getOverview: jest.fn(), getAgentMetrics: jest.fn() }; - controller = new DashboardController( - mockService as unknown as DashboardService - ); + controller = new DashboardController(mockService as unknown as DashboardService); }); describe('getOverview', () => { it('returns overview with default 24h range', async () => { const overview = { kpis: { - totalRuns: 10, activeRuns: 2, completedRuns: 7, failedRuns: 1, - cancelledRuns: 0, totalSignals: 5, totalTokens: 1500, totalCostUsd: 0.05, + totalRuns: 10, + activeRuns: 2, + completedRuns: 7, + failedRuns: 1, + cancelledRuns: 0, + totalSignals: 5, + totalTokens: 1500, + totalCostUsd: 0.05, avgDurationMs: 5000 }, recentRuns: [{ id: 'run-1', status: 'completed', runtimeKind: 'rust', createdAt: '2026-04-04T00:00:00Z' }], @@ -36,9 +40,7 @@ describe('DashboardController', () => { const result = await controller.getOverview({ range: undefined }); - expect(mockService.getOverview).toHaveBeenCalledWith( - expect.objectContaining({ window: '24h' }) - ); + expect(mockService.getOverview).toHaveBeenCalledWith(expect.objectContaining({ window: '24h' })); expect(result).toEqual(overview); expect(result.kpis.totalTokens).toBe(1500); expect(result.kpis.totalCostUsd).toBe(0.05); @@ -49,25 +51,19 @@ describe('DashboardController', () => { it('passes 7d range to service (via legacy range alias)', async () => { mockService.getOverview.mockResolvedValue({ kpis: {}, charts: {} }); await controller.getOverview({ range: '7d' }); - expect(mockService.getOverview).toHaveBeenCalledWith( - expect.objectContaining({ window: '7d' }) - ); + expect(mockService.getOverview).toHaveBeenCalledWith(expect.objectContaining({ window: '7d' })); }); it('passes 30d range to service (via legacy range alias)', async () => { mockService.getOverview.mockResolvedValue({ kpis: {}, charts: {} }); await controller.getOverview({ range: '30d' }); - expect(mockService.getOverview).toHaveBeenCalledWith( - expect.objectContaining({ window: '30d' }) - ); + expect(mockService.getOverview).toHaveBeenCalledWith(expect.objectContaining({ window: '30d' })); }); it('prefers the new `window` field over `range` alias (§5.1)', async () => { mockService.getOverview.mockResolvedValue({ kpis: {}, charts: {} }); await controller.getOverview({ window: '1h', range: '24h' } as any); - expect(mockService.getOverview).toHaveBeenCalledWith( - expect.objectContaining({ window: '1h' }) - ); + expect(mockService.getOverview).toHaveBeenCalledWith(expect.objectContaining({ window: '1h' })); }); it('passes scenarioRef + environment filters through (§5.1)', async () => { diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts index 855ec4b..aff3a01 100644 --- a/src/controllers/dashboard.controller.ts +++ b/src/controllers/dashboard.controller.ts @@ -9,7 +9,9 @@ export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} @Get('overview') - @ApiOperation({ summary: 'Aggregated dashboard KPIs and chart data (§5.1 — window + scenarioRef + environment filters).' }) + @ApiOperation({ + summary: 'Aggregated dashboard KPIs and chart data (§5.1 — window + scenarioRef + environment filters).' + }) @ApiOkResponse({ type: DashboardOverviewDto }) async getOverview( @Query(new ValidationPipe({ transform: true, whitelist: true })) diff --git a/src/controllers/events.controller.spec.ts b/src/controllers/events.controller.spec.ts index e3c4696..d615e3e 100644 --- a/src/controllers/events.controller.spec.ts +++ b/src/controllers/events.controller.spec.ts @@ -10,7 +10,7 @@ describe('EventsController (§4.1)', () => { listCanonicalFiltered: jest.fn().mockResolvedValue({ data: [{ id: 'e1', runId: 'r1', seq: 1, type: 'signal.emitted' }], total: 1 - }), + }) }; controller = new EventsController(mockRepo as unknown as EventRepository); }); @@ -28,7 +28,7 @@ describe('EventsController (§4.1)', () => { expect(mockRepo.listCanonicalFiltered).toHaveBeenCalledWith( expect.objectContaining({ - types: ['signal.emitted', 'signal.acknowledged', 'policy.denied'], + types: ['signal.emitted', 'signal.acknowledged', 'policy.denied'] }) ); }); @@ -40,7 +40,7 @@ describe('EventsController (§4.1)', () => { afterSeq: 100, afterTs: '2026-04-13T00:00:00Z', beforeTs: '2026-04-14T00:00:00Z', - limit: 50, + limit: 50 } as any); expect(mockRepo.listCanonicalFiltered).toHaveBeenCalledWith( @@ -50,7 +50,7 @@ describe('EventsController (§4.1)', () => { afterSeq: 100, afterTs: '2026-04-13T00:00:00Z', beforeTs: '2026-04-14T00:00:00Z', - limit: 50, + limit: 50 }) ); }); @@ -58,7 +58,7 @@ describe('EventsController (§4.1)', () => { it('sets nextCursor only when page is full (data.length === limit)', async () => { mockRepo.listCanonicalFiltered.mockResolvedValueOnce({ data: Array.from({ length: 50 }, (_, i) => ({ seq: i + 1 })), - total: 120, + total: 120 }); const result = await controller.listEvents({ limit: 50 } as any); @@ -69,7 +69,7 @@ describe('EventsController (§4.1)', () => { it('omits nextCursor when page is not full', async () => { mockRepo.listCanonicalFiltered.mockResolvedValueOnce({ data: [{ seq: 1 }, { seq: 2 }], - total: 2, + total: 2 }); const result = await controller.listEvents({ limit: 500 } as any); diff --git a/src/controllers/events.controller.ts b/src/controllers/events.controller.ts index 628fc23..344fe64 100644 --- a/src/controllers/events.controller.ts +++ b/src/controllers/events.controller.ts @@ -12,17 +12,20 @@ export class EventsController { @ApiOperation({ summary: 'Cross-run canonical events with filters (§4.1).' }) - async listEvents( - @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListCrossRunEventsQueryDto - ) { + async listEvents(@Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListCrossRunEventsQueryDto) { const { data, total } = await this.eventRepository.listCanonicalFiltered({ runId: query.runId, afterSeq: query.afterSeq, afterTs: query.afterTs, beforeTs: query.beforeTs, - types: query.type ? query.type.split(',').map((t) => t.trim()).filter(Boolean) : undefined, + types: query.type + ? query.type + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined, scenarioRef: query.scenarioRef, - limit: query.limit ?? 500, + limit: query.limit ?? 500 }); const limit = query.limit ?? 500; const nextCursor = data.length === limit ? data[data.length - 1].seq : undefined; diff --git a/src/controllers/health.controller.ts b/src/controllers/health.controller.ts index aa1a97f..aa2041b 100644 --- a/src/controllers/health.controller.ts +++ b/src/controllers/health.controller.ts @@ -47,7 +47,11 @@ export class HealthController { try { runtime = await this.runtimeRegistry.get(this.config.runtimeKind).health(); } catch (error) { - runtime = { ok: false, runtimeKind: this.config.runtimeKind, detail: error instanceof Error ? error.message : String(error) }; + runtime = { + ok: false, + runtimeKind: this.config.runtimeKind, + detail: error instanceof Error ? error.message : String(error) + }; } const streamHealthy = this.streamConsumer.isHealthy(); diff --git a/src/controllers/metrics.controller.spec.ts b/src/controllers/metrics.controller.spec.ts index 89754f7..67526ec 100644 --- a/src/controllers/metrics.controller.spec.ts +++ b/src/controllers/metrics.controller.spec.ts @@ -11,12 +11,10 @@ describe('MetricsController', () => { beforeEach(() => { mockInstrumentation = { getMetrics: jest.fn().mockResolvedValue('# HELP http_requests_total\nhttp_requests_total 42'), - getContentType: jest.fn().mockReturnValue('text/plain; version=0.0.4; charset=utf-8'), + getContentType: jest.fn().mockReturnValue('text/plain; version=0.0.4; charset=utf-8') }; - controller = new MetricsController( - mockInstrumentation as unknown as InstrumentationService, - ); + controller = new MetricsController(mockInstrumentation as unknown as InstrumentationService); }); describe('getMetrics', () => { @@ -29,13 +27,8 @@ describe('MetricsController', () => { expect(mockInstrumentation.getMetrics).toHaveBeenCalledTimes(1); expect(mockInstrumentation.getContentType).toHaveBeenCalledTimes(1); - expect(mockSet).toHaveBeenCalledWith( - 'Content-Type', - 'text/plain; version=0.0.4; charset=utf-8', - ); - expect(mockEnd).toHaveBeenCalledWith( - '# HELP http_requests_total\nhttp_requests_total 42', - ); + expect(mockSet).toHaveBeenCalledWith('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + expect(mockEnd).toHaveBeenCalledWith('# HELP http_requests_total\nhttp_requests_total 42'); }); it('should call getMetrics before writing response', async () => { @@ -46,7 +39,7 @@ describe('MetricsController', () => { }); const res = { set: jest.fn(() => callOrder.push('set')), - end: jest.fn(() => callOrder.push('end')), + end: jest.fn(() => callOrder.push('end')) } as any; await controller.getMetrics(res); diff --git a/src/controllers/observability.controller.spec.ts b/src/controllers/observability.controller.spec.ts index b1314b0..058d644 100644 --- a/src/controllers/observability.controller.spec.ts +++ b/src/controllers/observability.controller.spec.ts @@ -24,24 +24,24 @@ describe('ObservabilityController', () => { status: 'completed', sourceKind: 'scenario', sourceRef: 'fraud-detection@1.2.0' - }), + }) }; mockArtifactService = { list: jest.fn(), - register: jest.fn(), + register: jest.fn() }; mockMetricsService = { - get: jest.fn(), + get: jest.fn() }; mockProjectionService = { get: jest.fn(), - rebuild: jest.fn(), + rebuild: jest.fn() }; mockEventService = { - emitControlPlaneEvents: jest.fn().mockResolvedValue([]), + emitControlPlaneEvents: jest.fn().mockResolvedValue([]) }; mockEventRepository = { - listCanonicalUpTo: jest.fn(), + listCanonicalUpTo: jest.fn() }; controller = new ObservabilityController( @@ -50,7 +50,7 @@ describe('ObservabilityController', () => { mockMetricsService as unknown as MetricsService, mockProjectionService as unknown as ProjectionService, mockEventService as unknown as RunEventService, - mockEventRepository as unknown as EventRepository, + mockEventRepository as unknown as EventRepository ); }); @@ -69,7 +69,7 @@ describe('ObservabilityController', () => { expect(result).toEqual({ ...traceSummary, runStatus: 'completed', - scenarioRef: 'fraud-detection@1.2.0', + scenarioRef: 'fraud-detection@1.2.0' }); }); @@ -82,7 +82,7 @@ describe('ObservabilityController', () => { spanCount: 0, linkedArtifacts: [], runStatus: 'completed', - scenarioRef: 'fraud-detection@1.2.0', + scenarioRef: 'fraud-detection@1.2.0' }); }); @@ -95,7 +95,7 @@ describe('ObservabilityController', () => { spanCount: 0, linkedArtifacts: [], runStatus: 'completed', - scenarioRef: 'fraud-detection@1.2.0', + scenarioRef: 'fraud-detection@1.2.0' }); }); @@ -114,9 +114,7 @@ describe('ObservabilityController', () => { // =========================================================================== describe('getArtifacts', () => { it('delegates to artifactService.list', async () => { - const artifacts = [ - { id: 'art-1', runId, kind: 'json', label: 'result' }, - ]; + const artifacts = [{ id: 'art-1', runId, kind: 'json', label: 'result' }]; mockArtifactService.list.mockResolvedValue(artifacts); const result = await controller.getArtifacts(runId); @@ -138,14 +136,14 @@ describe('ObservabilityController', () => { kind: 'json', label: 'output', uri: 'https://example.com/output.json', - createdAt: '2026-03-19T00:00:00.000Z', + createdAt: '2026-03-19T00:00:00.000Z' }; mockArtifactService.register.mockResolvedValue(artifact); const body = { kind: 'json' as const, label: 'output', - uri: 'https://example.com/output.json', + uri: 'https://example.com/output.json' }; const result = await controller.createArtifact(runId, body as any); @@ -155,24 +153,21 @@ describe('ObservabilityController', () => { kind: 'json', label: 'output', uri: 'https://example.com/output.json', - inline: undefined, + inline: undefined }); - expect(mockEventService.emitControlPlaneEvents).toHaveBeenCalledWith( - runId, - [ - expect.objectContaining({ - type: 'artifact.created', - source: { kind: 'control-plane', name: 'observability-controller' }, - subject: { kind: 'artifact', id: 'art-new' }, - data: expect.objectContaining({ - kind: 'json', - label: 'output', - artifactId: 'art-new', - uri: 'https://example.com/output.json', - }), - }), - ], - ); + expect(mockEventService.emitControlPlaneEvents).toHaveBeenCalledWith(runId, [ + expect.objectContaining({ + type: 'artifact.created', + source: { kind: 'control-plane', name: 'observability-controller' }, + subject: { kind: 'artifact', id: 'art-new' }, + data: expect.objectContaining({ + kind: 'json', + label: 'output', + artifactId: 'art-new', + uri: 'https://example.com/output.json' + }) + }) + ]); expect(result).toEqual(artifact); }); @@ -183,21 +178,21 @@ describe('ObservabilityController', () => { kind: 'json', label: 'inline result', inline: { foo: 'bar' }, - createdAt: '2026-03-19T00:00:00.000Z', + createdAt: '2026-03-19T00:00:00.000Z' }; mockArtifactService.register.mockResolvedValue(artifact); const body = { kind: 'json' as const, label: 'inline result', - inline: { foo: 'bar' }, + inline: { foo: 'bar' } }; await controller.createArtifact(runId, body as any); expect(mockArtifactService.register).toHaveBeenCalledWith( expect.objectContaining({ - inline: { foo: 'bar' }, - }), + inline: { foo: 'bar' } + }) ); }); }); @@ -215,7 +210,7 @@ describe('ObservabilityController', () => { proposalCount: 2, toolCallCount: 5, decisionCount: 1, - streamReconnectCount: 0, + streamReconnectCount: 0 }; mockMetricsService.get.mockResolvedValue(metrics); @@ -239,7 +234,7 @@ describe('ObservabilityController', () => { proposalCount: 0, toolCallCount: 0, decisionCount: 0, - streamReconnectCount: 0, + streamReconnectCount: 0 }); }); }); @@ -251,13 +246,13 @@ describe('ObservabilityController', () => { it('calls rebuild with canonical events and returns result', async () => { const events = [ { id: 'e1', seq: 1, type: 'run.created' }, - { id: 'e2', seq: 2, type: 'session.started' }, + { id: 'e2', seq: 2, type: 'session.started' } ]; mockEventRepository.listCanonicalUpTo.mockResolvedValue(events); const rebuiltProjection = { run: { runId, status: 'completed' }, - timeline: { latestSeq: 2 }, + timeline: { latestSeq: 2 } }; mockProjectionService.rebuild.mockResolvedValue(rebuiltProjection); diff --git a/src/controllers/observability.controller.ts b/src/controllers/observability.controller.ts index d39e054..b54f658 100644 --- a/src/controllers/observability.controller.ts +++ b/src/controllers/observability.controller.ts @@ -80,16 +80,18 @@ export class ObservabilityController { @ApiOkResponse({ type: MetricsSummaryDto }) async getMetrics(@Param('id', new ParseUUIDPipe()) id: string) { await this.runManager.getRun(id); - return (await this.metricsService.get(id)) ?? { - runId: id, - eventCount: 0, - messageCount: 0, - signalCount: 0, - proposalCount: 0, - toolCallCount: 0, - decisionCount: 0, - streamReconnectCount: 0 - }; + return ( + (await this.metricsService.get(id)) ?? { + runId: id, + eventCount: 0, + messageCount: 0, + signalCount: 0, + proposalCount: 0, + toolCallCount: 0, + decisionCount: 0, + streamReconnectCount: 0 + } + ); } @Post('runs/:id/projection/rebuild') diff --git a/src/controllers/run-insights.controller.spec.ts b/src/controllers/run-insights.controller.spec.ts index 1b82503..3145b6e 100644 --- a/src/controllers/run-insights.controller.spec.ts +++ b/src/controllers/run-insights.controller.spec.ts @@ -97,9 +97,7 @@ describe('RunInsightsController', () => { describe('batchCancel', () => { it('cancels multiple runs and returns results', async () => { - mockRunExecutor.cancel - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('not found')); + mockRunExecutor.cancel.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('not found')); const result = await controller.batchCancel({ runIds: ['run-1', 'run-2'] }); @@ -116,23 +114,25 @@ describe('RunInsightsController', () => { it('exports multiple runs and returns bundles', async () => { const bundle1 = { run: { id: 'run-1' }, exportedAt: '2026-01-01T00:00:00Z' }; const bundle2 = { run: { id: 'run-2' }, exportedAt: '2026-01-01T00:00:00Z' }; - mockInsightsService.exportRun - .mockResolvedValueOnce(bundle1) - .mockResolvedValueOnce(bundle2); + mockInsightsService.exportRun.mockResolvedValueOnce(bundle1).mockResolvedValueOnce(bundle2); const result = await controller.batchExport({ runIds: ['run-1', 'run-2'] }); expect(result).toEqual([bundle1, bundle2]); - expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-1', { includeCanonical: true, includeRaw: false }); - expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-2', { includeCanonical: true, includeRaw: false }); + expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-1', { + includeCanonical: true, + includeRaw: false + }); + expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-2', { + includeCanonical: true, + includeRaw: false + }); }); }); describe('batchArchive', () => { it('archives multiple runs and returns results', async () => { - mockRunManager.archiveRun - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('not found')); + mockRunManager.archiveRun.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('not found')); const result = await controller.batchArchive({ runIds: ['run-1', 'run-2'] }); @@ -147,9 +147,7 @@ describe('RunInsightsController', () => { describe('batchDelete', () => { it('deletes multiple runs and returns results', async () => { - mockRunManager.deleteRun - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('run is not terminal')); + mockRunManager.deleteRun.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('run is not terminal')); const result = await controller.batchDelete({ runIds: ['run-1', 'run-2'] }); diff --git a/src/controllers/run-insights.controller.ts b/src/controllers/run-insights.controller.ts index 9fb0546..65ff1ef 100644 --- a/src/controllers/run-insights.controller.ts +++ b/src/controllers/run-insights.controller.ts @@ -1,15 +1,4 @@ -import { - Body, - Controller, - Get, - Header, - Param, - ParseUUIDPipe, - Post, - Query, - Res, - ValidationPipe -} from '@nestjs/common'; +import { Body, Controller, Get, Header, Param, ParseUUIDPipe, Post, Query, Res, ValidationPipe } from '@nestjs/common'; import type { Response } from 'express'; import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CompareRunsDto } from '../dto/compare-runs.dto'; @@ -80,24 +69,23 @@ export class RunInsightsController { @Post('batch/cancel') @ApiOperation({ summary: 'Cancel multiple runs in batch.' }) - async batchCancel( - @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] } - ) { - const results = await Promise.allSettled( - body.runIds.map((id) => this.runExecutor.cancel(id, 'batch cancel')) - ); + async batchCancel(@Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] }) { + const results = await Promise.allSettled(body.runIds.map((id) => this.runExecutor.cancel(id, 'batch cancel'))); return results.map((result, index) => ({ runId: body.runIds[index], status: result.status === 'fulfilled' ? 'cancelled' : 'failed', - error: result.status === 'rejected' ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined + error: + result.status === 'rejected' + ? result.reason instanceof Error + ? result.reason.message + : String(result.reason) + : undefined })); } @Post('batch/export') @ApiOperation({ summary: 'Export multiple runs in batch.' }) - async batchExport( - @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] } - ) { + async batchExport(@Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] }) { return Promise.all( body.runIds.map((id) => this.insightsService.exportRun(id, { includeCanonical: true, includeRaw: false })) ); @@ -105,31 +93,33 @@ export class RunInsightsController { @Post('batch/archive') @ApiOperation({ summary: 'Archive multiple runs in batch.' }) - async batchArchive( - @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] } - ) { - const results = await Promise.allSettled( - body.runIds.map((id) => this.runManager.archiveRun(id)) - ); + async batchArchive(@Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] }) { + const results = await Promise.allSettled(body.runIds.map((id) => this.runManager.archiveRun(id))); return results.map((result, index) => ({ runId: body.runIds[index], status: result.status === 'fulfilled' ? 'archived' : 'failed', - error: result.status === 'rejected' ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined + error: + result.status === 'rejected' + ? result.reason instanceof Error + ? result.reason.message + : String(result.reason) + : undefined })); } @Post('batch/delete') @ApiOperation({ summary: 'Delete multiple terminal runs in batch.' }) - async batchDelete( - @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] } - ) { - const results = await Promise.allSettled( - body.runIds.map((id) => this.runManager.deleteRun(id)) - ); + async batchDelete(@Body(new ValidationPipe({ transform: true, whitelist: true })) body: { runIds: string[] }) { + const results = await Promise.allSettled(body.runIds.map((id) => this.runManager.deleteRun(id))); return results.map((result, index) => ({ runId: body.runIds[index], status: result.status === 'fulfilled' ? 'deleted' : 'failed', - error: result.status === 'rejected' ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined + error: + result.status === 'rejected' + ? result.reason instanceof Error + ? result.reason.message + : String(result.reason) + : undefined })); } } diff --git a/src/controllers/runs.controller.spec.ts b/src/controllers/runs.controller.spec.ts index 5daef29..56398e0 100644 --- a/src/controllers/runs.controller.spec.ts +++ b/src/controllers/runs.controller.spec.ts @@ -41,17 +41,17 @@ describe('RunsController (observer mode)', () => { mockRunExecutor = { launch: jest.fn(), cancel: jest.fn(), - clone: jest.fn(), + clone: jest.fn() }; mockRunManager = { listRuns: jest.fn(), getRun: jest.fn(), getState: jest.fn(), deleteRun: jest.fn(), - archiveRun: jest.fn(), + archiveRun: jest.fn() }; mockEventRepository = { - listCanonicalByRun: jest.fn(), + listCanonicalByRun: jest.fn() }; mockReplayService = {}; mockStreamHub = {}; @@ -68,7 +68,10 @@ describe('RunsController (observer mode)', () => { mockConfig as unknown as AppConfigService, mockProjectionService as unknown as ProjectionService, mockOutboundMessageRepository as unknown as OutboundMessageRepository, - { activeSseConnections: { inc: jest.fn(), dec: jest.fn() }, signalsTotal: { inc: jest.fn() } } as unknown as InstrumentationService, + { + activeSseConnections: { inc: jest.fn(), dec: jest.fn() }, + signalsTotal: { inc: jest.fn() } + } as unknown as InstrumentationService ); }); @@ -83,7 +86,7 @@ describe('RunsController (observer mode)', () => { limit: 10, offset: 0, sortBy: 'createdAt' as const, - sortOrder: 'desc' as const, + sortOrder: 'desc' as const }; const result = await controller.listRuns(query as any); @@ -95,7 +98,7 @@ describe('RunsController (observer mode)', () => { mockRunManager.listRuns.mockResolvedValue([]); await controller.listRuns({} as any); expect(mockRunManager.listRuns).toHaveBeenCalledWith( - expect.objectContaining({ limit: 50, offset: 0, sortBy: 'createdAt', sortOrder: 'desc' }), + expect.objectContaining({ limit: 50, offset: 0, sortBy: 'createdAt', sortOrder: 'desc' }) ); }); }); @@ -113,8 +116,8 @@ describe('RunsController (observer mode)', () => { modeVersion: '1.0.0', configurationVersion: 'config.default', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }; const result = await controller.createRun(body as any); @@ -123,7 +126,7 @@ describe('RunsController (observer mode)', () => { runId: 'run-123', sessionId: 'sess-alloc-1', status: 'queued', - traceId: 'trace-abc', + traceId: 'trace-abc' }); }); }); @@ -192,9 +195,9 @@ describe('RunsController (observer mode)', () => { describe('cloneRun', () => { it('rejects context override (scenario-specific, not accepted by control-plane)', async () => { - await expect( - controller.cloneRun('run-1', { context: { some: 'thing' } } as any), - ).rejects.toThrow(BadRequestException); + await expect(controller.cloneRun('run-1', { context: { some: 'thing' } } as any)).rejects.toThrow( + BadRequestException + ); }); it('delegates to runExecutor.clone and returns {runId, sessionId, status, traceId}', async () => { @@ -208,7 +211,7 @@ describe('RunsController (observer mode)', () => { runId: 'run-2', sessionId: 'sess-2', status: 'queued', - traceId: 't2', + traceId: 't2' }); }); }); diff --git a/src/controllers/runs.controller.ts b/src/controllers/runs.controller.ts index 108802d..734c3df 100644 --- a/src/controllers/runs.controller.ts +++ b/src/controllers/runs.controller.ts @@ -14,16 +14,9 @@ import { Post, Query, Sse, - ValidationPipe, + ValidationPipe } from '@nestjs/common'; -import { - ApiAcceptedResponse, - ApiBody, - ApiOkResponse, - ApiOperation, - ApiQuery, - ApiTags, -} from '@nestjs/swagger'; +import { ApiAcceptedResponse, ApiBody, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { map, Observable } from 'rxjs'; import { CanonicalEvent, ReplayRequest, RunStatus } from '../contracts/control-plane'; import { AppConfigService } from '../config/app-config.service'; @@ -39,7 +32,7 @@ import { CanonicalEventDto, CreateRunResponseDto, ReplayDescriptorDto, - RunStateResponseDto, + RunStateResponseDto } from '../dto/run-responses.dto'; import { StreamHubService, StreamHubMessage } from '../events/stream-hub.service'; import { InstrumentationService } from '../telemetry/instrumentation.service'; @@ -60,12 +53,12 @@ function gone(endpoint: string): never { { statusCode: HttpStatus.GONE, errorCode: 'ENDPOINT_REMOVED', - message: `${endpoint} has been removed. Agents authenticate to the runtime directly via macp-sdk-python / macp-sdk-typescript. See ${MIGRATION_URL}`, + message: `${endpoint} has been removed. Agents authenticate to the runtime directly via macp-sdk-python / macp-sdk-typescript. See ${MIGRATION_URL}` }, HttpStatus.GONE, { - cause: new Error(`${endpoint} removed (direct-agent-auth)`), - }, + cause: new Error(`${endpoint} removed (direct-agent-auth)`) + } ); } @@ -81,7 +74,7 @@ export class RunsController { private readonly config: AppConfigService, private readonly projectionService: ProjectionService, private readonly outboundMessageRepository: OutboundMessageRepository, - private readonly instrumentation: InstrumentationService, + private readonly instrumentation: InstrumentationService ) {} @Post('validate') @@ -89,16 +82,14 @@ export class RunsController { @ApiBody({ type: RunDescriptorDto }) async validateRequest( @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - body: RunDescriptorDto, + body: RunDescriptorDto ) { return this.runExecutor.validate(body); } @Get() @ApiOperation({ summary: 'List runs with optional filtering and pagination.' }) - async listRuns( - @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListRunsQueryDto, - ) { + async listRuns(@Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListRunsQueryDto) { return this.runManager.listRuns({ status: query.status, tags: query.tags, @@ -112,27 +103,27 @@ export class RunsController { includeArchived: query.includeArchived, environment: query.environment, scenarioRef: query.scenarioRef, - search: query.search, + search: query.search }); } @Post() @ApiOperation({ summary: - 'Create and launch a runtime execution run. Returns {runId, sessionId} — caller distributes sessionId to agents via bootstrap.', + 'Create and launch a runtime execution run. Returns {runId, sessionId} — caller distributes sessionId to agents via bootstrap.' }) @ApiAcceptedResponse({ type: CreateRunResponseDto }) @ApiBody({ type: RunDescriptorDto }) async createRun( @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - body: RunDescriptorDto, + body: RunDescriptorDto ) { const { run, sessionId } = await this.runExecutor.launch(body); return { runId: run.id, sessionId, status: run.status as RunStatus, - traceId: run.traceId ?? undefined, + traceId: run.traceId ?? undefined } satisfies CreateRunResponseDto; } @@ -159,7 +150,7 @@ export class RunsController { @ApiOkResponse({ type: [CanonicalEventDto] }) async getRunEvents( @Param('id', new ParseUUIDPipe()) id: string, - @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListEventsQueryDto, + @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListEventsQueryDto ) { if (!query.afterTs && !query.beforeTs && !query.type) { return this.eventRepository.listCanonicalByRun(id, query.afterSeq ?? 0, query.limit ?? 200); @@ -169,8 +160,13 @@ export class RunsController { afterSeq: query.afterSeq, afterTs: query.afterTs, beforeTs: query.beforeTs, - types: query.type ? query.type.split(',').map((t) => t.trim()).filter(Boolean) : undefined, - limit: query.limit ?? 200, + types: query.type + ? query.type + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined, + limit: query.limit ?? 200 }); const limit = query.limit ?? 200; const nextCursor = data.length > 0 ? data[data.length - 1].seq : undefined; @@ -182,7 +178,7 @@ export class RunsController { streamRun( @Param('id', new ParseUUIDPipe()) id: string, @Query(new ValidationPipe({ transform: true, whitelist: true })) query: StreamRunQueryDto, - @Headers('last-event-id') lastEventId?: string, + @Headers('last-event-id') lastEventId?: string ): Observable { const afterSeq = query.afterSeq ?? (lastEventId ? Number(lastEventId) : 0); const includeSnapshot = query.includeSnapshot !== false; @@ -206,11 +202,11 @@ export class RunsController { subscriber.next({ type: msg.event, data: msg.data, - ...(seq !== undefined ? { id: String(seq) } : {}), + ...(seq !== undefined ? { id: String(seq) } : {}) } as MessageEvent); }, complete: () => subscriber.complete(), - error: (err) => subscriber.error(err), + error: (err) => subscriber.error(err) }); const heartbeatTimer = setInterval(() => { @@ -238,7 +234,7 @@ export class RunsController { subscriber.next({ type: 'canonical_event', data: event, - id: String(event.seq), + id: String(event.seq) } as MessageEvent); } if (events.length < batchSize) break; @@ -254,7 +250,7 @@ export class RunsController { subscriber.next({ type: msg.event, data: msg.data, - ...(seq !== undefined ? { id: String(seq) } : {}), + ...(seq !== undefined ? { id: String(seq) } : {}) } as MessageEvent); } buffer.length = 0; @@ -276,12 +272,12 @@ export class RunsController { @Post(':id/cancel') @ApiOperation({ summary: - 'Cancel a running session. Default: proxies to the initiator agent\'s cancelCallback (Option A). ' - + 'Policy-delegated fallback (metadata.cancellationDelegated=true) calls runtime.CancelSession (Option B).', + "Cancel a running session. Default: proxies to the initiator agent's cancelCallback (Option A). " + + 'Policy-delegated fallback (metadata.cancellationDelegated=true) calls runtime.CancelSession (Option B).' }) async cancelRun( @Param('id', new ParseUUIDPipe()) id: string, - @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { reason?: string }, + @Body(new ValidationPipe({ transform: true, whitelist: true })) body: { reason?: string } ) { return this.runExecutor.cancel(id, body?.reason); } @@ -291,13 +287,13 @@ export class RunsController { @ApiAcceptedResponse({ type: ReplayDescriptorDto }) async createReplay( @Param('id', new ParseUUIDPipe()) id: string, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) body: ReplayRequestDto, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) body: ReplayRequestDto ) { const replay: ReplayRequest = { mode: body.mode ?? 'timed', speed: body.speed ?? 1, fromSeq: body.fromSeq, - toSeq: body.toSeq, + toSeq: body.toSeq }; return this.replayService.describe(id, replay); } @@ -306,24 +302,21 @@ export class RunsController { @ApiOperation({ summary: 'Replay a run using persisted canonical events.' }) streamReplay( @Param('id', new ParseUUIDPipe()) id: string, - @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ReplayRequestDto, + @Query(new ValidationPipe({ transform: true, whitelist: true })) query: ReplayRequestDto ): Observable { return this.replayService .stream(id, { mode: query.mode ?? 'timed', speed: query.speed ?? 1, fromSeq: query.fromSeq, - toSeq: query.toSeq, + toSeq: query.toSeq }) .pipe(map((item) => ({ type: item.type, data: item.data }) as MessageEvent)); } @Get(':id/replay/state') @ApiOperation({ summary: 'Project run state at a specific event sequence for scrubber/replay UIs.' }) - async getReplayState( - @Param('id', new ParseUUIDPipe()) id: string, - @Query('seq') seq?: string, - ) { + async getReplayState(@Param('id', new ParseUUIDPipe()) id: string, @Query('seq') seq?: string) { return this.replayService.stateAt(id, seq ? Number(seq) : undefined); } @@ -334,7 +327,7 @@ export class RunsController { @Post(':id/messages') @ApiOperation({ summary: 'REMOVED. Agents emit session-bound messages via the macp-sdk directly.', - deprecated: true, + deprecated: true }) sendMessage(@Param('id', new ParseUUIDPipe()) _id: string): never { gone('POST /runs/:id/messages'); @@ -343,7 +336,7 @@ export class RunsController { @Post(':id/signal') @ApiOperation({ summary: 'REMOVED. Agents emit signals via the macp-sdk directly.', - deprecated: true, + deprecated: true }) sendSignal(@Param('id', new ParseUUIDPipe()) _id: string): never { gone('POST /runs/:id/signal'); @@ -352,7 +345,7 @@ export class RunsController { @Post(':id/context') @ApiOperation({ summary: 'REMOVED. Agents emit ContextUpdate envelopes via the macp-sdk directly.', - deprecated: true, + deprecated: true }) updateContext(@Param('id', new ParseUUIDPipe()) _id: string): never { gone('POST /runs/:id/context'); @@ -363,13 +356,13 @@ export class RunsController { @ApiBody({ type: CloneRunDto }) async cloneRun( @Param('id', new ParseUUIDPipe()) id: string, - @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) body: CloneRunDto, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) body: CloneRunDto ) { if (body.context && Object.keys(body.context).length > 0) { throw new BadRequestException( - 'context overrides are no longer accepted — session context is opaque to the control-plane ' - + '(direct-agent-auth §Invariants). Pass any scenario-specific overrides via the caller\'s ' - + 'scenario compiler and submit a fresh POST /runs.', + 'context overrides are no longer accepted — session context is opaque to the control-plane ' + + "(direct-agent-auth §Invariants). Pass any scenario-specific overrides via the caller's " + + 'scenario compiler and submit a fresh POST /runs.' ); } const { run, sessionId } = await this.runExecutor.clone(id, { tags: body.tags }); @@ -377,7 +370,7 @@ export class RunsController { runId: run.id, sessionId, status: run.status, - traceId: run.traceId ?? undefined, + traceId: run.traceId ?? undefined }; } diff --git a/src/controllers/runtime.controller.spec.ts b/src/controllers/runtime.controller.spec.ts index 9387ddf..1ab710a 100644 --- a/src/controllers/runtime.controller.spec.ts +++ b/src/controllers/runtime.controller.spec.ts @@ -20,20 +20,20 @@ describe('RuntimeController', () => { listModes: jest.fn(), listRoots: jest.fn(), health: jest.fn(), - registerPolicy: jest.fn(), + registerPolicy: jest.fn() }; mockConfig = { - runtimeKind: 'rust', + runtimeKind: 'rust' }; mockRuntimeRegistry = { - get: jest.fn().mockReturnValue(mockProvider), + get: jest.fn().mockReturnValue(mockProvider) }; controller = new RuntimeController( mockConfig as AppConfigService, - mockRuntimeRegistry as unknown as RuntimeProviderRegistry, + mockRuntimeRegistry as unknown as RuntimeProviderRegistry ); }); @@ -45,7 +45,7 @@ describe('RuntimeController', () => { const manifest = { agentId: 'rust-runtime', title: 'Rust Runtime', - supportedModes: ['macp.mode.decision.v1'], + supportedModes: ['macp.mode.decision.v1'] }; mockProvider.getManifest.mockResolvedValue(manifest); @@ -63,7 +63,7 @@ describe('RuntimeController', () => { describe('listModes', () => { it('delegates to provider.listModes via registry', async () => { const modes = [ - { mode: 'macp.mode.decision.v1', modeVersion: '1.0.0', messageTypes: [], terminalMessageTypes: [] }, + { mode: 'macp.mode.decision.v1', modeVersion: '1.0.0', messageTypes: [], terminalMessageTypes: [] } ]; mockProvider.listModes.mockResolvedValue(modes); diff --git a/src/controllers/runtime.controller.ts b/src/controllers/runtime.controller.ts index 101fa96..15300e2 100644 --- a/src/controllers/runtime.controller.ts +++ b/src/controllers/runtime.controller.ts @@ -54,13 +54,16 @@ export class RuntimeController { @ApiOperation({ summary: 'Register a governance policy with the runtime.' }) @ApiBody({ description: 'Policy descriptor with rules' }) @ApiOkResponse({ type: RuntimeRegisterPolicyResultDto }) - async registerPolicy(@Body() body: { - policyId: string; - mode: string; - description: string; - rules: Record; - schemaVersion?: number; - }) { + async registerPolicy( + @Body() + body: { + policyId: string; + mode: string; + description: string; + rules: Record; + schemaVersion?: number; + } + ) { // Pre-validate before forwarding to runtime if (!body.policyId || body.policyId.trim().length === 0) { throw new BadRequestException('policyId is required'); @@ -131,7 +134,8 @@ export class RuntimeController { const policy = await provider.getPolicy({ policyId }); return { ...policy, - rules: typeof policy.rules === 'string' ? JSON.parse(policy.rules) : JSON.parse(Buffer.from(policy.rules).toString()) + rules: + typeof policy.rules === 'string' ? JSON.parse(policy.rules) : JSON.parse(Buffer.from(policy.rules).toString()) }; } diff --git a/src/controllers/webhook.controller.spec.ts b/src/controllers/webhook.controller.spec.ts index 62e3e2c..ba4d484 100644 --- a/src/controllers/webhook.controller.spec.ts +++ b/src/controllers/webhook.controller.spec.ts @@ -17,12 +17,10 @@ describe('WebhookController', () => { register: jest.fn().mockResolvedValue({ id: 'wh-1' }), list: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue({ id: 'wh-1' }), - remove: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined) }; - controller = new WebhookController( - mockWebhookService as unknown as WebhookService, - ); + controller = new WebhookController(mockWebhookService as unknown as WebhookService); }); describe('createWebhook', () => { @@ -30,7 +28,7 @@ describe('WebhookController', () => { const body: CreateWebhookDto = { url: 'https://example.com/hook', events: ['run.completed', 'run.failed'], - secret: 's3cret', + secret: 's3cret' }; await controller.createWebhook(body); @@ -38,14 +36,14 @@ describe('WebhookController', () => { expect(mockWebhookService.register).toHaveBeenCalledWith({ url: 'https://example.com/hook', events: ['run.completed', 'run.failed'], - secret: 's3cret', + secret: 's3cret' }); }); it('should default events to empty array when not provided', async () => { const body = { url: 'https://example.com/hook', - secret: 's3cret', + secret: 's3cret' } as CreateWebhookDto; await controller.createWebhook(body); @@ -53,7 +51,7 @@ describe('WebhookController', () => { expect(mockWebhookService.register).toHaveBeenCalledWith({ url: 'https://example.com/hook', events: [], - secret: 's3cret', + secret: 's3cret' }); }); }); @@ -70,14 +68,14 @@ describe('WebhookController', () => { it('should call webhookService.update with id and body', async () => { const body: UpdateWebhookDto = { url: 'https://example.com/new-hook', - active: false, + active: false }; await controller.updateWebhook('wh-1', body); expect(mockWebhookService.update).toHaveBeenCalledWith('wh-1', { url: 'https://example.com/new-hook', - active: false, + active: false }); }); }); diff --git a/src/dashboard/dashboard.service.spec.ts b/src/dashboard/dashboard.service.spec.ts index 44f8dc0..40c938a 100644 --- a/src/dashboard/dashboard.service.spec.ts +++ b/src/dashboard/dashboard.service.spec.ts @@ -36,7 +36,15 @@ describe('DashboardService', () => { beforeEach(() => { mockDb = makeMockDb(); mockRunRepo = makeMockRunRepo([ - { id: 'run-1', status: 'completed', runtimeKind: 'rust', sourceRef: null, startedAt: null, endedAt: null, createdAt: '2026-04-04T00:00:00Z' } + { + id: 'run-1', + status: 'completed', + runtimeKind: 'rust', + sourceRef: null, + startedAt: null, + endedAt: null, + createdAt: '2026-04-04T00:00:00Z' + } ]); mockRuntimeRegistry = makeMockRuntimeRegistry(); service = new DashboardService(mockDb, mockRunRepo, mockRuntimeRegistry); @@ -48,7 +56,9 @@ describe('DashboardService', () => { const dbExecute = mockDb.db.execute as jest.Mock; dbExecute // getKpis: runs - .mockResolvedValueOnce({ rows: [{ totalRuns: 10, activeRuns: 2, completedRuns: 7, failedRuns: 1, cancelledRuns: 0 }] }) + .mockResolvedValueOnce({ + rows: [{ totalRuns: 10, activeRuns: 2, completedRuns: 7, failedRuns: 1, cancelledRuns: 0 }] + }) // getKpis: signals .mockResolvedValueOnce({ rows: [{ totalSignals: 5 }] }) // getKpis: tokens @@ -157,7 +167,9 @@ describe('DashboardService', () => { beforeEach(() => { const dbExecute = mockDb.db.execute as jest.Mock; dbExecute - .mockResolvedValueOnce({ rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] }) + .mockResolvedValueOnce({ + rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] + }) .mockResolvedValueOnce({ rows: [{ totalSignals: 0 }] }) .mockResolvedValueOnce({ rows: [{ totalTokens: 0, totalCostUsd: 0 }] }) .mockResolvedValueOnce({ rows: [] }) @@ -246,9 +258,7 @@ describe('DashboardService', () => { it('defaults averageConfidence to 0 when null', async () => { mockDb.db.execute.mockResolvedValue({ - rows: [ - { participantId: 'agent-a', runs: 1, messages: 1, signals: 0, averageConfidence: null } - ] + rows: [{ participantId: 'agent-a', runs: 1, messages: 1, signals: 0, averageConfidence: null }] }); const result = await service.getAgentMetrics(); @@ -258,9 +268,7 @@ describe('DashboardService', () => { it('coerces string values to numbers', async () => { mockDb.db.execute.mockResolvedValue({ - rows: [ - { participantId: 'agent-a', runs: '5', messages: '10', signals: '2', averageConfidence: '0.75' } - ] + rows: [{ participantId: 'agent-a', runs: '5', messages: '10', signals: '2', averageConfidence: '0.75' }] }); const result = await service.getAgentMetrics(); @@ -277,7 +285,9 @@ describe('DashboardService', () => { const dbExecute = mockDb.db.execute as jest.Mock; // Setup minimal overview mocks (getRuntimeHealth is called within getOverview) dbExecute - .mockResolvedValueOnce({ rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] }) + .mockResolvedValueOnce({ + rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] + }) .mockResolvedValueOnce({ rows: [{ totalSignals: 0 }] }) .mockResolvedValueOnce({ rows: [{ totalTokens: 0, totalCostUsd: 0 }] }) .mockResolvedValueOnce({ rows: [] }) @@ -301,7 +311,9 @@ describe('DashboardService', () => { const dbExecute = mockDb.db.execute as jest.Mock; dbExecute - .mockResolvedValueOnce({ rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] }) + .mockResolvedValueOnce({ + rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] + }) .mockResolvedValueOnce({ rows: [{ totalSignals: 0 }] }) .mockResolvedValueOnce({ rows: [{ totalTokens: 0, totalCostUsd: 0 }] }) .mockResolvedValueOnce({ rows: [] }) @@ -326,7 +338,9 @@ describe('DashboardService', () => { const dbExecute = mockDb.db.execute as jest.Mock; dbExecute - .mockResolvedValueOnce({ rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] }) + .mockResolvedValueOnce({ + rows: [{ totalRuns: 0, activeRuns: 0, completedRuns: 0, failedRuns: 0, cancelledRuns: 0 }] + }) .mockResolvedValueOnce({ rows: [{ totalSignals: 0 }] }) .mockResolvedValueOnce({ rows: [{ totalTokens: 0, totalCostUsd: 0 }] }) .mockResolvedValueOnce({ rows: [] }) diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 71f5168..692b155 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -28,15 +28,17 @@ function resolveWindow(opts: DashboardOverviewOptions): { } const window = opts.window ?? '24h'; const interval = - window === '1h' ? '1 hour' - : window === '6h' ? '6 hours' - : window === '24h' ? '24 hours' - : window === '7d' ? '7 days' - : '30 days'; + window === '1h' + ? '1 hour' + : window === '6h' + ? '6 hours' + : window === '24h' + ? '24 hours' + : window === '7d' + ? '7 days' + : '30 days'; const bucket: 'minute' | 'hour' | 'day' = - window === '1h' || window === '6h' ? 'minute' - : window === '24h' ? 'hour' - : 'day'; + window === '1h' || window === '6h' ? 'minute' : window === '24h' ? 'hour' : 'day'; return { cutoffSql: sql`now() - interval '${sql.raw(interval)}'`, bucket @@ -272,8 +274,7 @@ export class DashboardService { ORDER BY 1 `) ]); - const avgDurationMs = - (avgResult.rows[0] as Record | undefined)?.avgDurationMs ?? null; + const avgDurationMs = (avgResult.rows[0] as Record | undefined)?.avgDurationMs ?? null; return { avgDurationMs, @@ -403,9 +404,7 @@ export class DashboardService { // Net outcome per bucket (positive − negative) — UI can also read raw rows if needed return { labels: result.rows.map((r: Record) => String(r.bucket)), - data: result.rows.map((r: Record) => - Number(r.positive ?? 0) - Number(r.negative ?? 0) - ) + data: result.rows.map((r: Record) => Number(r.positive ?? 0) - Number(r.negative ?? 0)) }; } diff --git a/src/db/database.service.ts b/src/db/database.service.ts index dd7f03a..0370c48 100644 --- a/src/db/database.service.ts +++ b/src/db/database.service.ts @@ -31,15 +31,11 @@ export class DatabaseService implements OnModuleDestroy { } async tryAdvisoryLock(key: string): Promise { - const result = await this.db.execute( - sql`SELECT pg_try_advisory_lock(hashtext(${key})) AS acquired` - ); + const result = await this.db.execute(sql`SELECT pg_try_advisory_lock(hashtext(${key})) AS acquired`); return (result.rows[0] as { acquired: boolean })?.acquired === true; } async advisoryUnlock(key: string): Promise { - await this.db.execute( - sql`SELECT pg_advisory_unlock(hashtext(${key}))` - ); + await this.db.execute(sql`SELECT pg_advisory_unlock(hashtext(${key}))`); } } diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 69f0ac3..b4f1d5a 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -43,9 +43,7 @@ export async function runMigrations(databaseUrl: string): Promise { } // Get already-applied migrations - const result = await client.query<{ name: string }>( - `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name` - ); + const result = await client.query<{ name: string }>(`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`); const applied = new Set(result.rows.map((r) => r.name)); const pending = files.filter((f) => !applied.has(f)); @@ -63,10 +61,7 @@ export async function runMigrations(databaseUrl: string): Promise { await client.query('BEGIN'); try { await client.query(sql); - await client.query( - `INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, - [file] - ); + await client.query(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, [file]); await client.query('COMMIT'); console.log(` ✓ ${file}`); } catch (err) { @@ -84,9 +79,7 @@ export async function runMigrations(databaseUrl: string): Promise { // Allow standalone execution: node dist/db/migrate.js if (require.main === module) { - const databaseUrl = - process.env.DATABASE_URL ?? - 'postgres://postgres:postgres@localhost:5432/macp_control_plane'; + const databaseUrl = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/macp_control_plane'; runMigrations(databaseUrl).catch((err) => { console.error('Migration failed:', err instanceof Error ? err.message : String(err)); diff --git a/src/db/schema.ts b/src/db/schema.ts index 3912c04..cea8161 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -48,7 +48,9 @@ export const runs = pgTable( export const runtimeSessions = pgTable( 'runtime_sessions', { - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), runtimeKind: varchar('runtime_kind', { length: 64 }).notNull(), runtimeSessionId: varchar('runtime_session_id', { length: 255 }).notNull(), modeName: varchar('mode_name', { length: 255 }).notNull(), @@ -78,7 +80,9 @@ export const runEventsRaw = pgTable( 'run_events_raw', { id: uuid('id').primaryKey(), - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), seq: integer('seq').notNull(), ts: timestamp('ts', { withTimezone: true, mode: 'string' }).notNull(), kind: varchar('kind', { length: 64 }).notNull(), @@ -98,7 +102,9 @@ export const runEventsCanonical = pgTable( 'run_events_canonical', { id: uuid('id').primaryKey(), - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), seq: integer('seq').notNull(), ts: timestamp('ts', { withTimezone: true, mode: 'string' }).notNull(), type: varchar('type', { length: 128 }).notNull(), @@ -125,7 +131,9 @@ export const runEventsCanonical = pgTable( export const runProjections = pgTable( 'run_projections', { - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), version: integer('version').notNull().default(0), schemaVersion: integer('schema_version').notNull().default(1), runSummary: jsonb('run_summary').$type>().notNull().default({}), @@ -133,8 +141,14 @@ export const runProjections = pgTable( graph: jsonb('graph').$type>().notNull().default({ nodes: [], edges: [] }), decision: jsonb('decision').$type>().notNull().default({}), signals: jsonb('signals').$type>().notNull().default({ signals: [] }), - timeline: jsonb('timeline').$type>().notNull().default({ latestSeq: 0, totalEvents: 0, recent: [] }), - traceSummary: jsonb('trace_summary').$type>().notNull().default({ spanCount: 0, linkedArtifacts: [] }), + timeline: jsonb('timeline') + .$type>() + .notNull() + .default({ latestSeq: 0, totalEvents: 0, recent: [] }), + traceSummary: jsonb('trace_summary') + .$type>() + .notNull() + .default({ spanCount: 0, linkedArtifacts: [] }), progress: jsonb('progress').$type>().notNull().default({ entries: [] }), updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).notNull().defaultNow() }, @@ -147,7 +161,9 @@ export const runArtifacts = pgTable( 'run_artifacts', { id: uuid('id').primaryKey(), - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), kind: varchar('kind', { length: 64 }).notNull(), label: varchar('label', { length: 255 }).notNull(), uri: text('uri'), @@ -184,7 +200,9 @@ export const auditLog = pgTable( export const runMetrics = pgTable( 'run_metrics', { - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), eventCount: integer('event_count').notNull().default(0), messageCount: integer('message_count').notNull().default(0), signalCount: integer('signal_count').notNull().default(0), @@ -212,7 +230,9 @@ export const runOutboundMessages = pgTable( 'run_outbound_messages', { id: uuid('id').primaryKey(), - runId: uuid('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + runId: uuid('run_id') + .notNull() + .references(() => runs.id, { onDelete: 'cascade' }), runtimeSessionId: varchar('runtime_session_id', { length: 255 }).notNull(), messageId: varchar('message_id', { length: 255 }).notNull(), messageType: varchar('message_type', { length: 128 }).notNull(), @@ -254,7 +274,9 @@ export const webhookDeliveries = pgTable( 'webhook_deliveries', { id: uuid('id').primaryKey(), - webhookId: uuid('webhook_id').notNull().references(() => webhooks.id, { onDelete: 'cascade' }), + webhookId: uuid('webhook_id') + .notNull() + .references(() => webhooks.id, { onDelete: 'cascade' }), event: varchar('event', { length: 128 }).notNull(), runId: uuid('run_id').notNull(), payload: jsonb('payload').$type>().notNull(), diff --git a/src/dto/list-events-query.dto.ts b/src/dto/list-events-query.dto.ts index 6ee7ae3..72c1f3c 100644 --- a/src/dto/list-events-query.dto.ts +++ b/src/dto/list-events-query.dto.ts @@ -5,14 +5,14 @@ import { IsInt, IsISO8601, IsOptional, IsString, Min } from 'class-validator'; export class ListEventsQueryDto { @ApiPropertyOptional({ minimum: 0, default: 0 }) @IsOptional() - @Transform(({ value }) => (value === undefined || value === null) ? undefined : Number(value)) + @Transform(({ value }) => (value === undefined || value === null ? undefined : Number(value))) @IsInt() @Min(0) afterSeq?: number; @ApiPropertyOptional({ minimum: 1, default: 200 }) @IsOptional() - @Transform(({ value }) => (value === undefined || value === null) ? undefined : Number(value)) + @Transform(({ value }) => (value === undefined || value === null ? undefined : Number(value))) @IsInt() @Min(1) limit?: number; @@ -27,7 +27,9 @@ export class ListEventsQueryDto { @IsISO8601() beforeTs?: string; - @ApiPropertyOptional({ description: 'Comma-separated canonical event types to filter (e.g. signal.emitted,signal.acknowledged)' }) + @ApiPropertyOptional({ + description: 'Comma-separated canonical event types to filter (e.g. signal.emitted,signal.acknowledged)' + }) @IsOptional() @IsString() type?: string; diff --git a/src/dto/list-runs-query.dto.ts b/src/dto/list-runs-query.dto.ts index 739c0b2..25a3641 100644 --- a/src/dto/list-runs-query.dto.ts +++ b/src/dto/list-runs-query.dto.ts @@ -4,14 +4,16 @@ import { IsArray, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-vali import { RunStatus } from '../contracts/control-plane'; export class ListRunsQueryDto { - @ApiPropertyOptional({ enum: ['queued', 'starting', 'binding_session', 'running', 'completed', 'failed', 'cancelled'] }) + @ApiPropertyOptional({ + enum: ['queued', 'starting', 'binding_session', 'running', 'completed', 'failed', 'cancelled'] + }) @IsOptional() @IsIn(['queued', 'starting', 'binding_session', 'running', 'completed', 'failed', 'cancelled']) status?: RunStatus; @ApiPropertyOptional({ type: [String], description: 'Filter by tags (comma-separated)' }) @IsOptional() - @Transform(({ value }) => typeof value === 'string' ? value.split(',').map((s: string) => s.trim()) : value) + @Transform(({ value }) => (typeof value === 'string' ? value.split(',').map((s: string) => s.trim()) : value)) @IsArray() @IsString({ each: true }) tags?: string[]; @@ -28,7 +30,7 @@ export class ListRunsQueryDto { @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) @IsOptional() - @Transform(({ value }) => (value === undefined || value === null) ? undefined : Number(value)) + @Transform(({ value }) => (value === undefined || value === null ? undefined : Number(value))) @IsInt() @Min(1) @Max(200) @@ -36,7 +38,7 @@ export class ListRunsQueryDto { @ApiPropertyOptional({ default: 0, minimum: 0 }) @IsOptional() - @Transform(({ value }) => (value === undefined || value === null) ? undefined : Number(value)) + @Transform(({ value }) => (value === undefined || value === null ? undefined : Number(value))) @IsInt() @Min(0) offset?: number; diff --git a/src/dto/run-descriptor.dto.ts b/src/dto/run-descriptor.dto.ts index 9a38820..448c59e 100644 --- a/src/dto/run-descriptor.dto.ts +++ b/src/dto/run-descriptor.dto.ts @@ -145,4 +145,3 @@ export class RunDescriptorDto implements RunDescriptor { @Type(() => ExecutionConfigDto) execution?: ExecutionConfigDto; } - diff --git a/src/dto/run-responses.dto.ts b/src/dto/run-responses.dto.ts index 51777dc..16f2b90 100644 --- a/src/dto/run-responses.dto.ts +++ b/src/dto/run-responses.dto.ts @@ -1,5 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { CanonicalEvent, MetricsSummary, RunComparisonResult, RunExportBundle, RunStateProjection, RunStatus } from '../contracts/control-plane'; +import { + CanonicalEvent, + MetricsSummary, + RunComparisonResult, + RunExportBundle, + RunStateProjection, + RunStatus +} from '../contracts/control-plane'; export class CreateRunResponseDto { @ApiProperty() diff --git a/src/dto/runtime-responses.dto.ts b/src/dto/runtime-responses.dto.ts index ef1a4fa..0129502 100644 --- a/src/dto/runtime-responses.dto.ts +++ b/src/dto/runtime-responses.dto.ts @@ -29,7 +29,10 @@ export class RuntimePolicyDescriptorDto { @ApiProperty() policyId!: string; @ApiProperty() mode!: string; @ApiProperty() description!: string; - @ApiProperty({ description: 'Parsed policy rules object (RFC-MACP-0012 per-mode schema: voting, objection_handling, evaluation, commitment for decision; threshold, abstention, commitment for quorum; etc.)' }) + @ApiProperty({ + description: + 'Parsed policy rules object (RFC-MACP-0012 per-mode schema: voting, objection_handling, evaluation, commitment for decision; threshold, abstention, commitment for quorum; etc.)' + }) rules!: Record; @ApiProperty() schemaVersion!: number; @ApiPropertyOptional() registeredAtUnixMs?: number; diff --git a/src/errors/exception.filter.spec.ts b/src/errors/exception.filter.spec.ts index 6954510..e316a8f 100644 --- a/src/errors/exception.filter.spec.ts +++ b/src/errors/exception.filter.spec.ts @@ -21,13 +21,13 @@ describe('GlobalExceptionFilter', () => { switchToHttp: () => ({ getResponse: () => mockResponse, getRequest: jest.fn(), - getNext: jest.fn(), + getNext: jest.fn() }), getArgs: jest.fn(), getArgByIndex: jest.fn(), switchToRpc: jest.fn(), switchToWs: jest.fn(), - getType: jest.fn(), + getType: jest.fn() } as unknown as ArgumentsHost; }); @@ -35,11 +35,7 @@ describe('GlobalExceptionFilter', () => { // AppException // =========================================================================== it('handles AppException correctly', () => { - const exception = new AppException( - ErrorCode.RUN_NOT_FOUND, - 'Run not found', - HttpStatus.NOT_FOUND, - ); + const exception = new AppException(ErrorCode.RUN_NOT_FOUND, 'Run not found', HttpStatus.NOT_FOUND); filter.catch(exception, mockHost); @@ -47,17 +43,14 @@ describe('GlobalExceptionFilter', () => { expect(mockJson).toHaveBeenCalledWith({ statusCode: HttpStatus.NOT_FOUND, errorCode: ErrorCode.RUN_NOT_FOUND, - message: 'Run not found', + message: 'Run not found' }); }); it('handles AppException with metadata', () => { - const exception = new AppException( - ErrorCode.VALIDATION_ERROR, - 'Invalid payload', - HttpStatus.BAD_REQUEST, - { field: 'name' }, - ); + const exception = new AppException(ErrorCode.VALIDATION_ERROR, 'Invalid payload', HttpStatus.BAD_REQUEST, { + field: 'name' + }); filter.catch(exception, mockHost); @@ -66,7 +59,7 @@ describe('GlobalExceptionFilter', () => { statusCode: HttpStatus.BAD_REQUEST, errorCode: ErrorCode.VALIDATION_ERROR, message: 'Invalid payload', - metadata: { field: 'name' }, + metadata: { field: 'name' } }); }); @@ -77,7 +70,7 @@ describe('GlobalExceptionFilter', () => { const body = { statusCode: HttpStatus.FORBIDDEN, message: 'Forbidden resource', - error: 'Forbidden', + error: 'Forbidden' }; const exception = new HttpException(body, HttpStatus.FORBIDDEN); @@ -99,7 +92,7 @@ describe('GlobalExceptionFilter', () => { expect(mockJson).toHaveBeenCalledWith({ statusCode: HttpStatus.BAD_REQUEST, errorCode: ErrorCode.INTERNAL_ERROR, - message: 'Something went wrong', + message: 'Something went wrong' }); }); @@ -115,7 +108,7 @@ describe('GlobalExceptionFilter', () => { expect(mockJson).toHaveBeenCalledWith({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.INTERNAL_ERROR, - message: 'Internal server error', + message: 'Internal server error' }); }); @@ -131,7 +124,7 @@ describe('GlobalExceptionFilter', () => { expect(mockJson).toHaveBeenCalledWith({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.INTERNAL_ERROR, - message: 'Internal server error', + message: 'Internal server error' }); }); @@ -142,7 +135,7 @@ describe('GlobalExceptionFilter', () => { expect(mockJson).toHaveBeenCalledWith({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.INTERNAL_ERROR, - message: 'Internal server error', + message: 'Internal server error' }); }); }); diff --git a/src/errors/exception.filter.ts b/src/errors/exception.filter.ts index b477aa2..a17ae07 100644 --- a/src/errors/exception.filter.ts +++ b/src/errors/exception.filter.ts @@ -19,11 +19,11 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (exception instanceof HttpException) { const status = exception.getStatus(); const body = exception.getResponse(); - response.status(status).json( - typeof body === 'string' - ? { statusCode: status, errorCode: ErrorCode.INTERNAL_ERROR, message: body } - : body - ); + response + .status(status) + .json( + typeof body === 'string' ? { statusCode: status, errorCode: ErrorCode.INTERNAL_ERROR, message: body } : body + ); return; } diff --git a/src/events/event-normalizer.service.spec.ts b/src/events/event-normalizer.service.spec.ts index d8527d5..27867b0 100644 --- a/src/events/event-normalizer.service.spec.ts +++ b/src/events/event-normalizer.service.spec.ts @@ -16,10 +16,10 @@ function makeContext(overrides?: Partial): NormalizeContext { modeVersion: '1.0.0', configurationVersion: '1.0.0', ttlMs: 30000, - participants: [{ id: 'agent-a' }, { id: 'agent-b' }], - }, + participants: [{ id: 'agent-a' }, { id: 'agent-b' }] + } } as RunDescriptor, - ...overrides, + ...overrides }; } @@ -33,7 +33,7 @@ function makeEnvelope(overrides?: Record) { sender: 'agent-a', timestampUnixMs: Date.now(), payload: Buffer.from('{}'), - ...overrides, + ...overrides }; } @@ -44,13 +44,13 @@ describe('EventNormalizerService', () => { beforeEach(() => { protoRegistry = { decodeKnown: jest.fn().mockReturnValue(undefined), - getKnownTypeName: jest.fn().mockReturnValue(undefined), + getKnownTypeName: jest.fn().mockReturnValue(undefined) } as unknown as jest.Mocked; service = new EventNormalizerService( protoRegistry, { inboundMessagesTotal: { inc: jest.fn() } } as unknown as InstrumentationService, - { isActive: () => false, redact: (v: T) => v } as any, + { isActive: () => false, redact: (v: T) => v } as any ); }); @@ -59,7 +59,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-status', receivedAt: '2026-01-01T00:00:00.000Z', - streamStatus: { status: 'opened', detail: 'connected' }, + streamStatus: { status: 'opened', detail: 'connected' } }; const ctx = makeContext(); @@ -69,9 +69,7 @@ describe('EventNormalizerService', () => { expect(events[0].type).toBe('session.stream.opened'); expect(events[0].runId).toBe('run-1'); expect(events[0].subject).toEqual({ kind: 'session', id: 'session-1' }); - expect(events[0].data).toEqual( - expect.objectContaining({ status: 'opened', detail: 'connected' }), - ); + expect(events[0].data).toEqual(expect.objectContaining({ status: 'opened', detail: 'connected' })); }); }); @@ -87,8 +85,8 @@ describe('EventNormalizerService', () => { startedAtUnixMs: 1000, expiresAtUnixMs: 31000, modeVersion: '1.0.0', - configurationVersion: '1.0.0', - }, + configurationVersion: '1.0.0' + } }; const ctx = makeContext(); @@ -102,15 +100,15 @@ describe('EventNormalizerService', () => { sessionId: 'session-1', state: 'SESSION_STATE_OPEN', modeName: 'decision', - modeVersion: '1.0.0', - }), + modeVersion: '1.0.0' + }) ); }); it('should return empty array when session-snapshot has no sessionSnapshot data', () => { const raw: RawRuntimeEvent = { kind: 'session-snapshot', - receivedAt: '2026-01-01T00:00:00.000Z', + receivedAt: '2026-01-01T00:00:00.000Z' }; const ctx = makeContext(); @@ -126,7 +124,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -140,8 +138,8 @@ describe('EventNormalizerService', () => { messageType: 'Signal', messageId: 'msg-1', sender: 'agent-a', - sessionId: 'session-1', - }), + sessionId: 'session-1' + }) ); }); @@ -150,7 +148,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext(); @@ -159,9 +157,7 @@ describe('EventNormalizerService', () => { const participantSeen = events.find((e) => e.type === 'participant.seen'); expect(participantSeen).toBeDefined(); expect(participantSeen!.subject).toEqual({ kind: 'participant', id: 'new-agent' }); - expect(participantSeen!.data).toEqual( - expect.objectContaining({ participantId: 'new-agent' }), - ); + expect(participantSeen!.data).toEqual(expect.objectContaining({ participantId: 'new-agent' })); expect(ctx.knownParticipants.has('new-agent')).toBe(true); }); @@ -170,7 +166,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -185,7 +181,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -197,8 +193,8 @@ describe('EventNormalizerService', () => { expect(signalEmitted!.data).toEqual( expect.objectContaining({ messageType: 'Signal', - sender: 'agent-a', - }), + sender: 'agent-a' + }) ); }); @@ -210,7 +206,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -225,8 +221,8 @@ describe('EventNormalizerService', () => { const decoded = { some_payload: 'foo', metadata: { - llmCall: { model: 'gpt-4o-mini', promptTokens: 123, completionTokens: 45, latencyMs: 890 }, - }, + llmCall: { model: 'gpt-4o-mini', promptTokens: 123, completionTokens: 45, latencyMs: 890 } + } }; protoRegistry.decodeKnown.mockReturnValue(decoded); @@ -234,7 +230,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-04-14T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -247,20 +243,20 @@ describe('EventNormalizerService', () => { promptTokens: 123, completionTokens: 45, totalTokens: 168, - latencyMs: 890, + latencyMs: 890 }); }); it('synthesizes llm.call.completed from minimal tokenUsage shape (§3.3)', () => { protoRegistry.decodeKnown.mockReturnValue({ - tokenUsage: { promptTokens: 50, completionTokens: 10, model: 'claude-3-haiku' }, + tokenUsage: { promptTokens: 50, completionTokens: 10, model: 'claude-3-haiku' } }); const envelope = makeEnvelope({ messageType: 'Evaluation' }); const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-04-14T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -271,7 +267,7 @@ describe('EventNormalizerService', () => { expect(llm!.data.decodedPayload).toMatchObject({ model: 'claude-3-haiku', promptTokens: 50, - completionTokens: 10, + completionTokens: 10 }); }); @@ -282,7 +278,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-04-14T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -299,7 +295,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -318,7 +314,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -338,7 +334,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -354,7 +350,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -375,7 +371,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -384,7 +380,7 @@ describe('EventNormalizerService', () => { const progressEvents = events.filter((e) => e.type === 'progress.reported'); expect(progressEvents).toHaveLength(1); expect(progressEvents[0].data.decodedPayload).toEqual( - expect.objectContaining({ percentage: 50, message: 'halfway done' }), + expect.objectContaining({ percentage: 50, message: 'halfway done' }) ); }); @@ -395,7 +391,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -404,7 +400,7 @@ describe('EventNormalizerService', () => { const progressEvents = events.filter((e) => e.type === 'progress.reported'); expect(progressEvents).toHaveLength(1); expect(progressEvents[0].data.decodedPayload).toEqual( - expect.objectContaining({ percentage: 100, message: 'completed' }), + expect.objectContaining({ percentage: 100, message: 'completed' }) ); }); @@ -415,7 +411,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -423,9 +419,7 @@ describe('EventNormalizerService', () => { const progressEvents = events.filter((e) => e.type === 'progress.reported'); expect(progressEvents).toHaveLength(1); - expect(progressEvents[0].data.decodedPayload).toEqual( - expect.objectContaining({ message: 'out of memory' }), - ); + expect(progressEvents[0].data.decodedPayload).toEqual(expect.objectContaining({ message: 'out of memory' })); }); }); @@ -442,7 +436,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); @@ -471,15 +465,13 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-b']) }); const events = service.normalize('run-1', raw, ctx); - const ambientProgress = events.find( - (e) => e.type === 'progress.reported' && e.subject?.kind === 'participant', - ); + const ambientProgress = events.find((e) => e.type === 'progress.reported' && e.subject?.kind === 'participant'); expect(ambientProgress).toBeDefined(); const payload = (ambientProgress!.data as Record).decodedPayload as Record; expect(payload.percentage).toBeUndefined(); @@ -494,15 +486,13 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); const events = service.normalize('run-1', raw, ctx); - const ambientProgress = events.find( - (e) => e.type === 'progress.reported' && e.subject?.kind === 'participant', - ); + const ambientProgress = events.find((e) => e.type === 'progress.reported' && e.subject?.kind === 'participant'); expect(ambientProgress).toBeDefined(); const payload = (ambientProgress!.data as Record).decodedPayload as Record; expect(payload.message).toBe(''); @@ -515,22 +505,18 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); const events = service.normalize('run-1', raw, ctx); // Generic derive still produces a progress.reported with message subject - const genericProgress = events.filter( - (e) => e.type === 'progress.reported' && e.subject?.kind === 'message', - ); + const genericProgress = events.filter((e) => e.type === 'progress.reported' && e.subject?.kind === 'message'); expect(genericProgress.length).toBeGreaterThanOrEqual(1); // But no ambient progress with participant subject - const ambientProgress = events.filter( - (e) => e.type === 'progress.reported' && e.subject?.kind === 'participant', - ); + const ambientProgress = events.filter((e) => e.type === 'progress.reported' && e.subject?.kind === 'participant'); expect(ambientProgress).toHaveLength(0); }); @@ -542,15 +528,13 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['agent-a']) }); const events = service.normalize('run-1', raw, ctx); - const ambientProgress = events.find( - (e) => e.type === 'progress.reported' && e.subject?.kind === 'participant', - ); + const ambientProgress = events.find((e) => e.type === 'progress.reported' && e.subject?.kind === 'participant'); expect(ambientProgress).toBeDefined(); const payload = (ambientProgress!.data as Record).decodedPayload as Record; expect(payload.percentage).toBe(100); @@ -559,14 +543,18 @@ describe('EventNormalizerService', () => { describe('policy lifecycle events', () => { it('should emit policy.resolved for PolicyResolved messageType', () => { - const decoded = { policyId: 'policy.fraud.majority', policyVersion: 'policy.fraud.majority', description: 'Majority veto' }; + const decoded = { + policyId: 'policy.fraud.majority', + policyVersion: 'policy.fraud.majority', + description: 'Majority veto' + }; protoRegistry.decodeKnown.mockReturnValue(decoded); const envelope = makeEnvelope({ messageType: 'PolicyResolved', sender: 'runtime' }); const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['runtime']) }); @@ -587,7 +575,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['runtime']) }); @@ -606,7 +594,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', receivedAt: '2026-01-01T00:00:00.000Z', - envelope, + envelope }; const ctx = makeContext({ knownParticipants: new Set(['runtime']) }); @@ -632,7 +620,7 @@ describe('EventNormalizerService', () => { code: 'POLICY_DENIED', message: 'Voting quorum not met: 1 of 3 required' } - }, + } }; const ctx = makeContext(); @@ -662,7 +650,7 @@ describe('EventNormalizerService', () => { code: 'INVALID_SESSION_ID', message: 'Session not found' } - }, + } }; const ctx = makeContext(); @@ -684,8 +672,8 @@ describe('EventNormalizerService', () => { messageId: 'msg-1', sessionId: 'session-1', acceptedAtUnixMs: Date.now(), - sessionState: 'SESSION_STATE_OPEN', - }, + sessionState: 'SESSION_STATE_OPEN' + } }; const ctx = makeContext(); @@ -699,7 +687,7 @@ describe('EventNormalizerService', () => { it('should return empty array for stream-envelope without envelope data', () => { const raw: RawRuntimeEvent = { kind: 'stream-envelope', - receivedAt: '2026-01-01T00:00:00.000Z', + receivedAt: '2026-01-01T00:00:00.000Z' }; const ctx = makeContext(); @@ -714,7 +702,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-status', receivedAt: '2026-01-01T00:00:00.000Z', - streamStatus: { status: 'opened' }, + streamStatus: { status: 'opened' } }; const ctx = makeContext(); @@ -723,7 +711,7 @@ describe('EventNormalizerService', () => { expect(events[0].source).toEqual({ kind: 'runtime', name: 'rust-runtime', - rawType: 'stream-status', + rawType: 'stream-status' }); }); @@ -732,7 +720,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-status', receivedAt: ts, - streamStatus: { status: 'opened' }, + streamStatus: { status: 'opened' } }; const ctx = makeContext(); @@ -745,7 +733,7 @@ describe('EventNormalizerService', () => { const raw: RawRuntimeEvent = { kind: 'stream-status', receivedAt: '2026-01-01T00:00:00.000Z', - streamStatus: { status: 'opened' }, + streamStatus: { status: 'opened' } }; const ctx = makeContext(); diff --git a/src/events/event-normalizer.service.ts b/src/events/event-normalizer.service.ts index ee2fd6b..20f8db1 100644 --- a/src/events/event-normalizer.service.ts +++ b/src/events/event-normalizer.service.ts @@ -19,13 +19,20 @@ export class EventNormalizerService implements EventNormalizer { const ts = rawEvent.receivedAt; if (rawEvent.kind === 'stream-status') { return [ - this.makeEvent(runId, ts, 'session.stream.opened', { - kind: 'session', - id: ctx.runtimeSessionId - }, { - status: rawEvent.streamStatus?.status, - detail: rawEvent.streamStatus?.detail - }, 'stream-status') + this.makeEvent( + runId, + ts, + 'session.stream.opened', + { + kind: 'session', + id: ctx.runtimeSessionId + }, + { + status: rawEvent.streamStatus?.status, + detail: rawEvent.streamStatus?.detail + }, + 'stream-status' + ) ]; } @@ -80,7 +87,9 @@ export class EventNormalizerService implements EventNormalizer { try { const parsed = JSON.parse(Buffer.from(rawEvent.ack.error.details).toString('utf-8')); if (Array.isArray(parsed.reasons)) reasons = parsed.reasons; - } catch { /* ignore parse errors */ } + } catch { + /* ignore parse errors */ + } } if (reasons.length === 0) { reasons = [rawEvent.ack.error.message]; @@ -128,11 +137,18 @@ export class EventNormalizerService implements EventNormalizer { // If it's a policy denial, also emit policy.denied if (err.code === 'POLICY_DENIED') { events.push( - this.makeEvent(runId, ts, 'policy.denied', { kind: 'policy', id: err.messageId || '' }, { - errorCode: err.code, - errorMessage: err.message, - decodedPayload: { decision: 'deny', reasons: [err.message] } - }, 'stream-inline-error') + this.makeEvent( + runId, + ts, + 'policy.denied', + { kind: 'policy', id: err.messageId || '' }, + { + errorCode: err.code, + errorMessage: err.message, + decodedPayload: { decision: 'deny', reasons: [err.message] } + }, + 'stream-inline-error' + ) ); } return events; @@ -233,33 +249,57 @@ export class EventNormalizerService implements EventNormalizer { const progress = (decoded as Record).progress; if (progress !== undefined) { canonical.push( - this.makeEvent(runId, ts, 'progress.reported', { kind: 'message', id: envelope.messageId }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: { percentage: progress, message: (decoded as Record).status ?? '' } - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'progress.reported', + { kind: 'message', id: envelope.messageId }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: { percentage: progress, message: (decoded as Record).status ?? '' } + }, + envelope.messageType + ) ); } } if (envelope.messageType === 'TaskComplete') { canonical.push( - this.makeEvent(runId, ts, 'progress.reported', { kind: 'message', id: envelope.messageId }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: { percentage: 100, message: 'completed' } - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'progress.reported', + { kind: 'message', id: envelope.messageId }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: { percentage: 100, message: 'completed' } + }, + envelope.messageType + ) ); } if (envelope.messageType === 'TaskFail') { canonical.push( - this.makeEvent(runId, ts, 'progress.reported', { kind: 'message', id: envelope.messageId }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: { percentage: undefined, message: (decoded as Record | undefined)?.reason ?? 'failed' } - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'progress.reported', + { kind: 'message', id: envelope.messageId }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: { + percentage: undefined, + message: (decoded as Record | undefined)?.reason ?? 'failed' + } + }, + envelope.messageType + ) ); } @@ -267,37 +307,61 @@ export class EventNormalizerService implements EventNormalizer { if (envelope.messageType === 'PolicyResolved' && decoded) { const policyPayload = decoded as Record; canonical.push( - this.makeEvent(runId, ts, 'policy.resolved', { kind: 'policy', id: String(policyPayload.policyId ?? policyPayload.policyVersion ?? '') }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - policyVersion: policyPayload.policyVersion ?? policyPayload.policyId, - decodedPayload: policyPayload - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'policy.resolved', + { kind: 'policy', id: String(policyPayload.policyId ?? policyPayload.policyVersion ?? '') }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + policyVersion: policyPayload.policyVersion ?? policyPayload.policyId, + decodedPayload: policyPayload + }, + envelope.messageType + ) ); } if (envelope.messageType === 'PolicyCommitmentEvaluated' && decoded) { const evalPayload = decoded as Record; canonical.push( - this.makeEvent(runId, ts, 'policy.commitment.evaluated', { kind: 'policy', id: String(evalPayload.commitmentId ?? '') }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: evalPayload - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'policy.commitment.evaluated', + { kind: 'policy', id: String(evalPayload.commitmentId ?? '') }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: evalPayload + }, + envelope.messageType + ) ); } - if (envelope.messageType === 'PolicyDenied' || (decoded && (decoded as Record).policyDenied === true)) { + if ( + envelope.messageType === 'PolicyDenied' || + (decoded && (decoded as Record).policyDenied === true) + ) { const denyPayload = (decoded ?? {}) as Record; canonical.push( - this.makeEvent(runId, ts, 'policy.denied', { kind: 'policy', id: String(denyPayload.commitmentId ?? denyPayload.policyId ?? '') }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: denyPayload - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'policy.denied', + { kind: 'policy', id: String(denyPayload.commitmentId ?? denyPayload.policyId ?? '') }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: denyPayload + }, + envelope.messageType + ) ); } @@ -305,17 +369,24 @@ export class EventNormalizerService implements EventNormalizer { if (envelope.messageType === 'Progress' && decoded) { const progressPayload = decoded as Record; canonical.push( - this.makeEvent(runId, ts, 'progress.reported', { kind: 'participant', id: envelope.sender }, { - modeName: envelope.mode, - messageType: envelope.messageType, - sender: envelope.sender, - decodedPayload: { - percentage: progressPayload.progress != null ? Number(progressPayload.progress) * 100 : undefined, - message: String(progressPayload.message ?? ''), - progressToken: progressPayload.progressToken, - total: progressPayload.total - } - }, envelope.messageType) + this.makeEvent( + runId, + ts, + 'progress.reported', + { kind: 'participant', id: envelope.sender }, + { + modeName: envelope.mode, + messageType: envelope.messageType, + sender: envelope.sender, + decodedPayload: { + percentage: progressPayload.progress != null ? Number(progressPayload.progress) * 100 : undefined, + message: String(progressPayload.message ?? ''), + progressToken: progressPayload.progressToken, + total: progressPayload.total + } + }, + envelope.messageType + ) ); } @@ -385,7 +456,8 @@ export class EventNormalizerService implements EventNormalizer { return { kind: 'proposal', id: String(proposalId ?? envelope.messageId) }; } case 'decision.finalized': { - const decisionId = payload?.commitmentId ?? payload?.commitment_id ?? payload?.decisionId ?? payload?.decision_id; + const decisionId = + payload?.commitmentId ?? payload?.commitment_id ?? payload?.decisionId ?? payload?.decision_id; return { kind: 'decision', id: String(decisionId ?? envelope.messageId) }; } case 'tool.called': @@ -448,7 +520,7 @@ function extractLlmCall(payload?: Record | null): Record | undefined, // `tokenUsage` is the minimal form — upgrade it to an llmCall shape. payload.tokenUsage as Record | undefined, - meta?.tokenUsage as Record | undefined, + meta?.tokenUsage as Record | undefined ]; for (const c of candidates) { if (!c || typeof c !== 'object') continue; @@ -459,7 +531,7 @@ function extractLlmCall(payload?: Record | null): Record = { promptTokens, completionTokens, - totalTokens: promptTokens + completionTokens, + totalTokens: promptTokens + completionTokens }; if (c.model) out.model = String(c.model); if (c.latencyMs != null) out.latencyMs = Number(c.latencyMs); diff --git a/src/events/redis-stream-hub.strategy.ts b/src/events/redis-stream-hub.strategy.ts index 6fad7af..b7b464d 100644 --- a/src/events/redis-stream-hub.strategy.ts +++ b/src/events/redis-stream-hub.strategy.ts @@ -20,8 +20,15 @@ export class RedisStreamHubStrategy implements StreamHubStrategy { private readonly logger = new Logger(RedisStreamHubStrategy.name); private readonly localSubject = new Subject(); private readonly completedRuns = new Set(); - private publisher: { publish: (channel: string, message: string) => Promise; quit: () => Promise } | null = null; - private subscriber: { subscribe: (channel: string, cb?: (err: Error | null) => void) => void; on: (event: string, cb: (...args: unknown[]) => void) => void; quit: () => Promise } | null = null; + private publisher: { + publish: (channel: string, message: string) => Promise; + quit: () => Promise; + } | null = null; + private subscriber: { + subscribe: (channel: string, cb?: (err: Error | null) => void) => void; + on: (event: string, cb: (...args: unknown[]) => void) => void; + quit: () => Promise; + } | null = null; private readonly channel = 'macp:stream-hub'; constructor(redisUrl: string) { @@ -90,9 +97,7 @@ export class RedisStreamHubStrategy implements StreamHubStrategy { } stream(runId: string): Observable { - return this.localSubject.asObservable().pipe( - filter((msg) => msg._runId === runId) - ); + return this.localSubject.asObservable().pipe(filter((msg) => msg._runId === runId)); } destroy(): void { diff --git a/src/events/run-event.service.spec.ts b/src/events/run-event.service.spec.ts index 49f6381..ab00525 100644 --- a/src/events/run-event.service.spec.ts +++ b/src/events/run-event.service.spec.ts @@ -29,7 +29,10 @@ describe('RunEventService', () => { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }, + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }; beforeEach(() => { @@ -37,30 +40,30 @@ describe('RunEventService', () => { database = { db: { - transaction: jest.fn(async (cb: (tx: any) => Promise) => cb(mockTx)), - }, + transaction: jest.fn(async (cb: (tx: any) => Promise) => cb(mockTx)) + } } as unknown as jest.Mocked; runRepository = { - allocateSequence: jest.fn().mockResolvedValue(1), + allocateSequence: jest.fn().mockResolvedValue(1) } as unknown as jest.Mocked; eventRepository = { appendRaw: jest.fn().mockResolvedValue(undefined), - appendCanonical: jest.fn().mockResolvedValue(undefined), + appendCanonical: jest.fn().mockResolvedValue(undefined) } as unknown as jest.Mocked; projectionService = { - applyAndPersist: jest.fn().mockResolvedValue(fakeProjection), + applyAndPersist: jest.fn().mockResolvedValue(fakeProjection) } as unknown as jest.Mocked; metricsService = { - recordEvents: jest.fn().mockResolvedValue({}), + recordEvents: jest.fn().mockResolvedValue({}) } as unknown as jest.Mocked; streamHub = { publishEvent: jest.fn(), - publishSnapshot: jest.fn(), + publishSnapshot: jest.fn() } as unknown as jest.Mocked; service = new RunEventService( @@ -74,8 +77,8 @@ describe('RunEventService', () => { withRunSpan: jest.fn((_runId: string, _name: string, _attrs: unknown, fn: () => Promise) => fn()), withSpan: jest.fn((_name: string, _attrs: unknown, fn: () => Promise) => fn()), addRunSpanEvent: jest.fn(), - getRunTraceContext: jest.fn().mockReturnValue(undefined), - } as any, + getRunTraceContext: jest.fn().mockReturnValue(undefined) + } as any ); }); @@ -98,15 +101,15 @@ describe('RunEventService', () => { type: 'run.created' as const, source: { kind: 'control-plane' as const, name: 'run-manager' }, subject: { kind: 'run' as const, id: 'run-1' }, - data: { status: 'queued' }, + data: { status: 'queued' } }, { ts: '2026-01-01T00:00:01.000Z', type: 'run.started' as const, source: { kind: 'control-plane' as const, name: 'run-manager' }, subject: { kind: 'run' as const, id: 'run-1' }, - data: { status: 'starting' }, - }, + data: { status: 'starting' } + } ]; const result = await service.emitControlPlaneEvents('run-1', partialEvents); @@ -144,8 +147,8 @@ describe('RunEventService', () => { type: 'session.stream.opened' as const, source: { kind: 'control-plane' as const, name: 'stream-consumer' }, subject: { kind: 'session' as const, id: 'session-1' }, - data: { status: 'reconnecting', detail: 'stream retry' }, - }, + data: { status: 'reconnecting', detail: 'stream retry' } + } ]; const result = await service.emitControlPlaneEvents('run-1', partialEvents); @@ -173,8 +176,8 @@ describe('RunEventService', () => { type: 'run.created' as const, source: { kind: 'control-plane' as const, name: 'run-manager' }, subject: { kind: 'run' as const, id: 'run-1' }, - data: { status: 'queued' }, - }, + data: { status: 'queued' } + } ]; await service.emitControlPlaneEvents('run-1', partialEvents); @@ -189,7 +192,7 @@ describe('RunEventService', () => { describe('persistRawAndCanonical', () => { const rawEvent: RawRuntimeEvent = { kind: 'stream-envelope', - receivedAt: '2026-01-01T00:00:00.000Z', + receivedAt: '2026-01-01T00:00:00.000Z' }; const canonicalEvents: CanonicalEvent[] = [ @@ -200,7 +203,7 @@ describe('RunEventService', () => { ts: '2026-01-01T00:00:00.000Z', type: 'message.received', source: { kind: 'runtime', name: 'rust-runtime' }, - data: { messageType: 'Signal' }, + data: { messageType: 'Signal' } }, { id: 'evt-2', @@ -209,8 +212,8 @@ describe('RunEventService', () => { ts: '2026-01-01T00:00:01.000Z', type: 'signal.emitted', source: { kind: 'runtime', name: 'rust-runtime' }, - data: { messageType: 'Signal' }, - }, + data: { messageType: 'Signal' } + } ]; it('should persist both raw and canonical events with correct sequences', async () => { @@ -279,7 +282,7 @@ describe('RunEventService', () => { ts: '2026-01-01T00:00:00.000Z', type: 'message.received', source: { kind: 'runtime', name: 'rust-runtime' }, - data: {}, + data: {} }, { id: '', @@ -288,8 +291,8 @@ describe('RunEventService', () => { ts: '2026-01-01T00:00:00.000Z', type: 'signal.emitted', source: { kind: 'runtime', name: 'rust-runtime' }, - data: {}, - }, + data: {} + } ]; const result = await service.persistRawAndCanonical('run-1', rawEvent, eventsWithMixedIds); diff --git a/src/events/run-event.service.ts b/src/events/run-event.service.ts index 05cd20a..af21bee 100644 --- a/src/events/run-event.service.ts +++ b/src/events/run-event.service.ts @@ -10,17 +10,20 @@ import { RunRepository } from '../storage/run.repository'; import { TraceService } from '../telemetry/trace.service'; import { StreamHubService } from './stream-hub.service'; -const KEY_EVENT_SPAN_ANNOTATIONS: Record string | number | boolean | undefined>> = { +const KEY_EVENT_SPAN_ANNOTATIONS: Record< + string, + Record string | number | boolean | undefined> +> = { 'signal.emitted': { name: (e) => String((e.data.decodedPayload as Record | undefined)?.signalType ?? e.type), - sender: (e) => (e.data.sender as string | undefined) ?? '', + sender: (e) => (e.data.sender as string | undefined) ?? '' }, 'signal.acknowledged': { signalId: (e) => String(e.subject?.id ?? ''), - sender: (e) => (e.data.sender as string | undefined) ?? '', + sender: (e) => (e.data.sender as string | undefined) ?? '' }, 'policy.denied': { - errorCode: (e) => (e.data.errorCode as string | undefined) ?? '', + errorCode: (e) => (e.data.errorCode as string | undefined) ?? '' }, 'decision.finalized': { action: (e) => String((e.data.decodedPayload as Record | undefined)?.action ?? ''), @@ -28,8 +31,8 @@ const KEY_EVENT_SPAN_ANNOTATIONS: Record | undefined; const v = p?.outcomePositive ?? p?.outcome_positive; return v === undefined ? undefined : Boolean(v); - }, - }, + } + } }; @Injectable() @@ -54,26 +57,29 @@ export class RunEventService { // events so they correlate with the waterfall (§6d). const runCtx = this.traceService.getRunTraceContext(runId); const stamped = runCtx - ? partialEvents.map((event) => (event.trace ? event : { ...event, trace: { traceId: runCtx.traceId, spanId: runCtx.spanId } })) + ? partialEvents.map((event) => + event.trace ? event : { ...event, trace: { traceId: runCtx.traceId, spanId: runCtx.spanId } } + ) : partialEvents; const { events, projection } = await this.traceService.withRunSpan( runId, 'run-event.emit', { 'macp.event_count': stamped.length }, - () => this.database.db.transaction(async (tx) => { - const startSeq = await this.runRepository.allocateSequence(runId, stamped.length); - const prepared = stamped.map((event, index) => ({ - ...event, - id: randomUUID(), - runId, - seq: startSeq + index, - schemaVersion: PROJECTION_SCHEMA_VERSION - })); - await this.eventRepository.appendCanonical(prepared, tx); - const proj = await this.projectionService.applyAndPersist(runId, prepared, tx); - return { events: prepared, projection: proj }; - }) + () => + this.database.db.transaction(async (tx) => { + const startSeq = await this.runRepository.allocateSequence(runId, stamped.length); + const prepared = stamped.map((event, index) => ({ + ...event, + id: randomUUID(), + runId, + seq: startSeq + index, + schemaVersion: PROJECTION_SCHEMA_VERSION + })); + await this.eventRepository.appendCanonical(prepared, tx); + const proj = await this.projectionService.applyAndPersist(runId, prepared, tx); + return { events: prepared, projection: proj }; + }) ); this.recordSpanEvents(runId, events); @@ -95,25 +101,28 @@ export class RunEventService { // even when the runtime's OTEL exporter isn't yet emitting `references[]`. const runCtx = this.traceService.getRunTraceContext(runId); const stamped = runCtx - ? canonicalEvents.map((event) => (event.trace?.traceId ? event : { ...event, trace: { traceId: runCtx.traceId, spanId: runCtx.spanId } })) + ? canonicalEvents.map((event) => + event.trace?.traceId ? event : { ...event, trace: { traceId: runCtx.traceId, spanId: runCtx.spanId } } + ) : canonicalEvents; const { normalized, projection } = await this.traceService.withRunSpan( runId, 'run-event.persist', { 'macp.event_count': stamped.length, 'macp.raw_kind': rawEvent.kind }, - () => this.database.db.transaction(async (tx) => { - const startSeq = await this.runRepository.allocateSequence(runId, total); - await this.eventRepository.appendRaw(runId, startSeq, rawEvent, tx); - const prepared = stamped.map((event, index) => ({ - ...event, - seq: startSeq + index + 1, - id: event.id || randomUUID() - })); - await this.eventRepository.appendCanonical(prepared, tx); - const proj = await this.projectionService.applyAndPersist(runId, prepared, tx); - return { normalized: prepared, projection: proj }; - }) + () => + this.database.db.transaction(async (tx) => { + const startSeq = await this.runRepository.allocateSequence(runId, total); + await this.eventRepository.appendRaw(runId, startSeq, rawEvent, tx); + const prepared = stamped.map((event, index) => ({ + ...event, + seq: startSeq + index + 1, + id: event.id || randomUUID() + })); + await this.eventRepository.appendCanonical(prepared, tx); + const proj = await this.projectionService.applyAndPersist(runId, prepared, tx); + return { normalized: prepared, projection: proj }; + }) ); this.recordSpanEvents(runId, normalized); diff --git a/src/events/stream-hub.service.spec.ts b/src/events/stream-hub.service.spec.ts index 044a095..64d73c7 100644 --- a/src/events/stream-hub.service.spec.ts +++ b/src/events/stream-hub.service.spec.ts @@ -24,13 +24,13 @@ describe('StreamHubService', () => { ts: new Date().toISOString(), type: 'message.sent', source: { kind: 'runtime', name: 'test-runtime' }, - data: { content: `event-${seq}` }, + data: { content: `event-${seq}` } }); const makeSnapshot = (runId: string): RunStateProjection => ({ run: { runId, - status: 'running', + status: 'running' }, participants: [], graph: { nodes: [], edges: [] }, @@ -41,7 +41,10 @@ describe('StreamHubService', () => { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }, + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }); describe('publishEvent()', () => { @@ -115,7 +118,7 @@ describe('StreamHubService', () => { const sub = service.stream('run-1').subscribe({ complete: () => { completed = true; - }, + } }); service.complete('run-1'); @@ -141,10 +144,7 @@ describe('StreamHubService', () => { sub.unsubscribe(); // A cleanup timer should now be scheduled - const timers = (strategy as any).cleanupTimers as Map< - string, - ReturnType - >; + const timers = (strategy as any).cleanupTimers as Map>; expect(timers.has('run-1')).toBe(true); // complete() should clear it @@ -185,10 +185,7 @@ describe('StreamHubService', () => { const sub2 = service.stream('run-1').subscribe(); // The cleanup timer should have been cancelled - const timers = (strategy as any).cleanupTimers as Map< - string, - ReturnType - >; + const timers = (strategy as any).cleanupTimers as Map>; expect(timers.has('run-1')).toBe(false); // Advance past the original cleanup time @@ -230,12 +227,12 @@ describe('StreamHubService', () => { const sub1 = service.stream('run-1').subscribe({ complete: () => { completed1 = true; - }, + } }); const sub2 = service.stream('run-2').subscribe({ complete: () => { completed2 = true; - }, + } }); service.complete('run-1'); @@ -260,15 +257,15 @@ describe('StreamHubService', () => { const completions: string[] = []; const sub1 = service.stream('run-1').subscribe({ - complete: () => completions.push('run-1'), + complete: () => completions.push('run-1') }); const sub2 = service.stream('run-2').subscribe({ - complete: () => completions.push('run-2'), + complete: () => completions.push('run-2') }); // Create a cleanup timer by unsubscribing from a stream const sub3 = service.stream('run-3').subscribe({ - complete: () => completions.push('run-3'), + complete: () => completions.push('run-3') }); sub3.unsubscribe(); @@ -281,10 +278,7 @@ describe('StreamHubService', () => { // All internal maps should be cleared const subjects = (strategy as any).subjects as Map; const counts = (strategy as any).subscriberCounts as Map; - const timers = (strategy as any).cleanupTimers as Map< - string, - ReturnType - >; + const timers = (strategy as any).cleanupTimers as Map>; expect(subjects.size).toBe(0); expect(counts.size).toBe(0); diff --git a/src/events/stream-hub.service.ts b/src/events/stream-hub.service.ts index 5ab04b1..d5072de 100644 --- a/src/events/stream-hub.service.ts +++ b/src/events/stream-hub.service.ts @@ -12,9 +12,7 @@ export interface StreamHubMessage { @Injectable() export class StreamHubService implements OnModuleDestroy { - constructor( - @Inject(STREAM_HUB_STRATEGY) private readonly strategy: StreamHubStrategy - ) {} + constructor(@Inject(STREAM_HUB_STRATEGY) private readonly strategy: StreamHubStrategy) {} onModuleDestroy(): void { if ('destroy' in this.strategy && typeof this.strategy.destroy === 'function') { diff --git a/src/insights/run-insights.service.spec.ts b/src/insights/run-insights.service.spec.ts index 3d72b92..e874954 100644 --- a/src/insights/run-insights.service.spec.ts +++ b/src/insights/run-insights.service.spec.ts @@ -134,13 +134,9 @@ describe('RunInsightsService', () => { decision: { current: { confidence: 0.8 } } }; - mockProjectionService.get - .mockResolvedValueOnce(leftProjection) - .mockResolvedValueOnce(rightProjection); + mockProjectionService.get.mockResolvedValueOnce(leftProjection).mockResolvedValueOnce(rightProjection); - mockMetricsRepo.get - .mockResolvedValueOnce({ durationMs: 1000 }) - .mockResolvedValueOnce({ durationMs: 1500 }); + mockMetricsRepo.get.mockResolvedValueOnce({ durationMs: 1000 }).mockResolvedValueOnce({ durationMs: 1500 }); const result = await service.compareRuns('run-1', 'run-2'); diff --git a/src/insights/run-insights.service.ts b/src/insights/run-insights.service.ts index 8a3e44d..dbea26c 100644 --- a/src/insights/run-insights.service.ts +++ b/src/insights/run-insights.service.ts @@ -36,19 +36,14 @@ export class RunInsightsService { const includeRaw = options.includeRaw === true; const eventLimit = options.eventLimit ?? 10000; - const [session, projection, metrics, artifacts, canonicalEvents, rawEvents] = - await Promise.all([ - this.runtimeSessionRepository.findByRunId(runId), - this.projectionService.get(runId), - this.metricsRepository.get(runId), - this.artifactRepository.listByRunId(runId), - includeCanonical - ? this.eventRepository.listCanonicalByRun(runId, 0, eventLimit) - : Promise.resolve([]), - includeRaw - ? this.eventRepository.listRawByRun(runId, 0, eventLimit) - : Promise.resolve([]) - ]); + const [session, projection, metrics, artifacts, canonicalEvents, rawEvents] = await Promise.all([ + this.runtimeSessionRepository.findByRunId(runId), + this.projectionService.get(runId), + this.metricsRepository.get(runId), + this.artifactRepository.listByRunId(runId), + includeCanonical ? this.eventRepository.listCanonicalByRun(runId, 0, eventLimit) : Promise.resolve([]), + includeRaw ? this.eventRepository.listRawByRun(runId, 0, eventLimit) : Promise.resolve([]) + ]); return { run: this.toRun(run), @@ -96,15 +91,17 @@ export class RunInsightsService { const bundle = await this.exportRun(runId, options); const lines: string[] = []; - lines.push(JSON.stringify({ - type: 'header', - run: bundle.run, - session: bundle.session, - projection: bundle.projection, - metrics: bundle.metrics, - artifacts: bundle.artifacts, - exportedAt: bundle.exportedAt - })); + lines.push( + JSON.stringify({ + type: 'header', + run: bundle.run, + session: bundle.session, + projection: bundle.projection, + metrics: bundle.metrics, + artifacts: bundle.artifacts, + exportedAt: bundle.exportedAt + }) + ); for (const event of bundle.canonicalEvents) { lines.push(JSON.stringify({ ...event, type: 'canonical_event' })); @@ -117,10 +114,7 @@ export class RunInsightsService { return lines.join('\n') + '\n'; } - async *exportRunStream( - runId: string, - options: { includeRaw?: boolean } - ): AsyncGenerator { + async *exportRunStream(runId: string, options: { includeRaw?: boolean }): AsyncGenerator { const run = await this.runRepository.findById(runId); if (!run) throw new NotFoundException(`run ${runId} not found`); @@ -137,27 +131,34 @@ export class RunInsightsService { run: this.toRun(run), session: session as Record | null, projection, - metrics: metrics ? { - runId: metrics.runId, - eventCount: metrics.eventCount, - messageCount: metrics.messageCount, - signalCount: metrics.signalCount, - proposalCount: metrics.proposalCount, - toolCallCount: metrics.toolCallCount, - decisionCount: metrics.decisionCount, - streamReconnectCount: metrics.streamReconnectCount, - promptTokens: Number(metrics.promptTokens ?? 0), - completionTokens: Number(metrics.completionTokens ?? 0), - totalTokens: Number(metrics.totalTokens ?? 0), - estimatedCostUsd: Number(metrics.estimatedCostUsd ?? 0), - firstEventAt: metrics.firstEventAt ?? undefined, - lastEventAt: metrics.lastEventAt ?? undefined, - durationMs: metrics.durationMs ?? undefined, - sessionState: (metrics.sessionState as MetricsSummary['sessionState']) ?? undefined - } : null, + metrics: metrics + ? { + runId: metrics.runId, + eventCount: metrics.eventCount, + messageCount: metrics.messageCount, + signalCount: metrics.signalCount, + proposalCount: metrics.proposalCount, + toolCallCount: metrics.toolCallCount, + decisionCount: metrics.decisionCount, + streamReconnectCount: metrics.streamReconnectCount, + promptTokens: Number(metrics.promptTokens ?? 0), + completionTokens: Number(metrics.completionTokens ?? 0), + totalTokens: Number(metrics.totalTokens ?? 0), + estimatedCostUsd: Number(metrics.estimatedCostUsd ?? 0), + firstEventAt: metrics.firstEventAt ?? undefined, + lastEventAt: metrics.lastEventAt ?? undefined, + durationMs: metrics.durationMs ?? undefined, + sessionState: (metrics.sessionState as MetricsSummary['sessionState']) ?? undefined + } + : null, artifacts: artifacts.map((a) => ({ - id: a.id, runId: a.runId, kind: a.kind, label: a.label, - uri: a.uri ?? undefined, inline: a.inline ?? undefined, createdAt: a.createdAt + id: a.id, + runId: a.runId, + kind: a.kind, + label: a.label, + uri: a.uri ?? undefined, + inline: a.inline ?? undefined, + createdAt: a.createdAt })), exportedAt: new Date().toISOString() }) + '\n'; @@ -191,38 +192,26 @@ export class RunInsightsService { this.metricsRepository.get(rightRunId) ]); - const leftParticipants = new Set( - leftProjection?.participants.map((p) => p.participantId) ?? [] - ); - const rightParticipants = new Set( - rightProjection?.participants.map((p) => p.participantId) ?? [] - ); + const leftParticipants = new Set(leftProjection?.participants.map((p) => p.participantId) ?? []); + const rightParticipants = new Set(rightProjection?.participants.map((p) => p.participantId) ?? []); const commonParticipants = [...leftParticipants].filter((p) => rightParticipants.has(p)); const addedParticipants = [...rightParticipants].filter((p) => !leftParticipants.has(p)); const removedParticipants = [...leftParticipants].filter((p) => !rightParticipants.has(p)); - const leftSignals = new Set( - leftProjection?.signals.signals.map((s) => s.name) ?? [] - ); - const rightSignals = new Set( - rightProjection?.signals.signals.map((s) => s.name) ?? [] - ); + const leftSignals = new Set(leftProjection?.signals.signals.map((s) => s.name) ?? []); + const rightSignals = new Set(rightProjection?.signals.signals.map((s) => s.name) ?? []); const addedSignals = [...rightSignals].filter((s) => !leftSignals.has(s)); const removedSignals = [...leftSignals].filter((s) => !rightSignals.has(s)); const leftDuration = leftMetrics?.durationMs ?? undefined; const rightDuration = rightMetrics?.durationMs ?? undefined; const durationDeltaMs = - leftDuration !== undefined && rightDuration !== undefined - ? rightDuration - leftDuration - : undefined; + leftDuration !== undefined && rightDuration !== undefined ? rightDuration - leftDuration : undefined; const leftConfidence = leftProjection?.decision.current?.confidence; const rightConfidence = rightProjection?.decision.current?.confidence; const confidenceDelta = - leftConfidence !== undefined && rightConfidence !== undefined - ? rightConfidence - leftConfidence - : undefined; + leftConfidence !== undefined && rightConfidence !== undefined ? rightConfidence - leftConfidence : undefined; return { left: { diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index 58b81fe..adaa6a6 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -50,13 +50,13 @@ function extractTokenUsage(event: CanonicalEvent): { /** Default per-model cost rates (USD per 1M tokens). Configurable via env in future. */ const MODEL_COSTS: Record = { - 'gpt-4o': { prompt: 2.50, completion: 10.00 }, - 'gpt-4o-mini': { prompt: 0.15, completion: 0.60 }, - 'gpt-4-turbo': { prompt: 10.00, completion: 30.00 }, - 'claude-3-opus': { prompt: 15.00, completion: 75.00 }, - 'claude-3-sonnet': { prompt: 3.00, completion: 15.00 }, + 'gpt-4o': { prompt: 2.5, completion: 10.0 }, + 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, + 'gpt-4-turbo': { prompt: 10.0, completion: 30.0 }, + 'claude-3-opus': { prompt: 15.0, completion: 75.0 }, + 'claude-3-sonnet': { prompt: 3.0, completion: 15.0 }, 'claude-3-haiku': { prompt: 0.25, completion: 1.25 }, - default: { prompt: 1.00, completion: 3.00 } + default: { prompt: 1.0, completion: 3.0 } }; function estimateCost(promptTokens: number, completionTokens: number, model?: string): number { @@ -126,18 +126,14 @@ export class MetricsService { const data = event.data as Record; const model = (data.tokenUsage as Record | undefined)?.model ?? - ((data.metadata as Record | undefined)?.tokenUsage as Record | undefined)?.model; - estimatedCostUsd += estimateCost( - usage.promptTokens, - usage.completionTokens, - model ? String(model) : undefined - ); + ((data.metadata as Record | undefined)?.tokenUsage as Record | undefined) + ?.model; + estimatedCostUsd += estimateCost(usage.promptTokens, usage.completionTokens, model ? String(model) : undefined); } } - const durationMs = firstEventAt && lastEventAt - ? new Date(lastEventAt).getTime() - new Date(firstEventAt).getTime() - : undefined; + const durationMs = + firstEventAt && lastEventAt ? new Date(lastEventAt).getTime() - new Date(firstEventAt).getTime() : undefined; const persisted = await this.repository.upsert(runId, { runId, @@ -159,13 +155,26 @@ export class MetricsService { counters: {} }); - return this.toSummary(runId, persisted ?? { - eventCount, messageCount, signalCount, proposalCount, - toolCallCount, decisionCount, streamReconnectCount, - promptTokens, completionTokens, totalTokens, - estimatedCostUsd: String(estimatedCostUsd), - firstEventAt, lastEventAt, durationMs, sessionState - }); + return this.toSummary( + runId, + persisted ?? { + eventCount, + messageCount, + signalCount, + proposalCount, + toolCallCount, + decisionCount, + streamReconnectCount, + promptTokens, + completionTokens, + totalTokens, + estimatedCostUsd: String(estimatedCostUsd), + firstEventAt, + lastEventAt, + durationMs, + sessionState + } + ); } async get(runId: string): Promise { diff --git a/src/middleware/request-logger.middleware.ts b/src/middleware/request-logger.middleware.ts index b5efd46..6fb3266 100644 --- a/src/middleware/request-logger.middleware.ts +++ b/src/middleware/request-logger.middleware.ts @@ -26,9 +26,7 @@ export class RequestLoggerMiddleware implements NestMiddleware { path, status_code: String(statusCode) }); - this.logger.log( - JSON.stringify({ method, path: originalUrl, statusCode, durationMs: duration, requestId }) - ); + this.logger.log(JSON.stringify({ method, path: originalUrl, statusCode, durationMs: duration, requestId })); }); next(); @@ -36,8 +34,6 @@ export class RequestLoggerMiddleware implements NestMiddleware { /** Collapse UUID path segments to `:id` to avoid high-cardinality labels. */ private normalizePath(url: string): string { - return url - .split('?')[0] - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id'); + return url.split('?')[0].replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id'); } } diff --git a/src/projection/projection-coverage.spec.ts b/src/projection/projection-coverage.spec.ts index 8a22e9a..63cb5ca 100644 --- a/src/projection/projection-coverage.spec.ts +++ b/src/projection/projection-coverage.spec.ts @@ -16,10 +16,7 @@ import { CANONICAL_EVENT_TYPES } from '../contracts/control-plane'; * through a full projection state to observe its effect. */ describe('Projection coverage invariant — every canonical event has a reducer', () => { - const projectionSource = readFileSync( - join(__dirname, 'projection.service.ts'), - 'utf8', - ); + const projectionSource = readFileSync(join(__dirname, 'projection.service.ts'), 'utf8'); // A type is "covered" if the literal `case 'foo':` appears in projection.service.ts // OR if it's a documented intentional no-op. @@ -36,7 +33,7 @@ describe('Projection coverage invariant — every canonical event has a reducer' 'tool.completed', // policy.denied is visible via the policy projection via commitment evaluations // and the event list; no dedicated reducer branch required today. - 'policy.denied', + 'policy.denied' ]); for (const eventType of CANONICAL_EVENT_TYPES) { diff --git a/src/projection/projection.service.spec.ts b/src/projection/projection.service.spec.ts index 52a3f8a..0846636 100644 --- a/src/projection/projection.service.spec.ts +++ b/src/projection/projection.service.spec.ts @@ -16,7 +16,7 @@ function makeEvent(overrides: Partial & { type: string }): Canon subject: overrides.subject, source: overrides.source ?? { kind: 'runtime', name: 'rust-runtime' }, trace: overrides.trace, - data: overrides.data ?? {}, + data: overrides.data ?? {} }; } @@ -26,7 +26,7 @@ function makeEvent(overrides: Partial & { type: string }): Canon const mockProjectionRepository: jest.Mocked = { get: jest.fn(), - upsert: jest.fn(), + upsert: jest.fn() } as unknown as jest.Mocked; // --------------------------------------------------------------------------- @@ -60,7 +60,10 @@ describe('ProjectionService', () => { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }, + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }); }); }); @@ -77,8 +80,8 @@ describe('ProjectionService', () => { data: { status: 'starting', modeName: 'decision', - traceId: 'trace-abc', - }, + traceId: 'trace-abc' + } }); const result = service.applyEvents(base, [event]); @@ -93,7 +96,7 @@ describe('ProjectionService', () => { const base = service.empty('run-1'); const event = makeEvent({ type: 'run.completed', - data: { status: 'completed', endedAt: '2026-01-01T01:00:00Z' }, + data: { status: 'completed', endedAt: '2026-01-01T01:00:00Z' } }); const result = service.applyEvents(base, [event]); @@ -112,7 +115,7 @@ describe('ProjectionService', () => { const base = service.empty('run-1'); const event = makeEvent({ type: 'participant.seen', - data: { participantId: 'agent-A' }, + data: { participantId: 'agent-A' } }); const result = service.applyEvents(base, [event]); @@ -128,13 +131,13 @@ describe('ProjectionService', () => { const event1 = makeEvent({ type: 'participant.seen', seq: 1, - data: { participantId: 'agent-A' }, + data: { participantId: 'agent-A' } }); const event2 = makeEvent({ type: 'participant.seen', id: 'evt-2', seq: 2, - data: { participantId: 'agent-A' }, + data: { participantId: 'agent-A' } }); const result = service.applyEvents(base, [event1, event2]); @@ -156,8 +159,8 @@ describe('ProjectionService', () => { data: { sender: 'agent-A', to: ['agent-B', 'agent-C'], - messageType: 'proposal', - }, + messageType: 'proposal' + } }); const result = service.applyEvents(base, [event]); @@ -183,13 +186,13 @@ describe('ProjectionService', () => { from: 'agent-A', to: 'agent-B', kind: 'message.sent', - ts: '2026-01-01T00:00:00Z', + ts: '2026-01-01T00:00:00Z' }); expect(result.graph.edges[1]).toEqual({ from: 'agent-A', to: 'agent-C', kind: 'message.sent', - ts: '2026-01-01T00:00:00Z', + ts: '2026-01-01T00:00:00Z' }); }); @@ -201,7 +204,7 @@ describe('ProjectionService', () => { from: `sender-${i}`, to: `recipient-${i}`, kind: 'message.sent', - ts: '2026-01-01T00:00:00Z', + ts: '2026-01-01T00:00:00Z' }); } @@ -211,8 +214,8 @@ describe('ProjectionService', () => { data: { sender: 'agent-X', to: ['agent-Y', 'agent-Z', 'agent-W'], - messageType: 'notify', - }, + messageType: 'notify' + } }); const result = service.applyEvents(base, [event]); @@ -223,7 +226,7 @@ describe('ProjectionService', () => { from: 'agent-X', to: 'agent-W', kind: 'message.sent', - ts: '2026-01-01T00:00:00Z', + ts: '2026-01-01T00:00:00Z' }); }); }); @@ -240,8 +243,8 @@ describe('ProjectionService', () => { subject: { kind: 'signal', id: 'sig-1' }, data: { sender: 'agent-A', - decodedPayload: { signalType: 'anomaly', severity: 'high', confidence: 0.95 }, - }, + decodedPayload: { signalType: 'anomaly', severity: 'high', confidence: 0.95 } + } }); const result = service.applyEvents(base, [event]); @@ -254,7 +257,7 @@ describe('ProjectionService', () => { sourceParticipantId: 'agent-A', ts: '2026-01-01T00:00:00Z', confidence: 0.95, - payload: { signalType: 'anomaly', severity: 'high', confidence: 0.95 }, + payload: { signalType: 'anomaly', severity: 'high', confidence: 0.95 } }); }); @@ -265,8 +268,8 @@ describe('ProjectionService', () => { subject: { kind: 'signal', id: 'sig-42' }, data: { sender: 'agent-A', - decodedPayload: { signalType: 'anomaly', severity: 'high', detail: { score: 0.97 } }, - }, + decodedPayload: { signalType: 'anomaly', severity: 'high', detail: { score: 0.97 } } + } }); const result = service.applyEvents(base, [event]); @@ -275,7 +278,7 @@ describe('ProjectionService', () => { expect(result.signals.signals[0].payload).toEqual({ signalType: 'anomaly', severity: 'high', - detail: { score: 0.97 }, + detail: { score: 0.97 } }); }); @@ -289,8 +292,8 @@ describe('ProjectionService', () => { subject: { kind: 'signal', id: 'sig-1' }, data: { sender: 'agent-A', - decodedPayload: { signalType: 'anomaly' }, - }, + decodedPayload: { signalType: 'anomaly' } + } }); const ack = makeEvent({ type: 'signal.acknowledged', @@ -300,8 +303,8 @@ describe('ProjectionService', () => { subject: { kind: 'signal', id: 'sig-1' }, data: { sender: 'agent-B', - decodedPayload: { signalId: 'sig-1' }, - }, + decodedPayload: { signalId: 'sig-1' } + } }); const result = service.applyEvents(base, [emitted, ack]); @@ -316,7 +319,7 @@ describe('ProjectionService', () => { const ack = makeEvent({ type: 'signal.acknowledged', subject: { kind: 'signal', id: 'sig-missing' }, - data: { sender: 'agent-B', decodedPayload: { signalId: 'sig-missing' } }, + data: { sender: 'agent-B', decodedPayload: { signalId: 'sig-missing' } } }); const result = service.applyEvents(base, [ack]); @@ -331,7 +334,7 @@ describe('ProjectionService', () => { base.signals.signals.push({ id: `sig-${i}`, name: 'old', - ts: '2026-01-01T00:00:00Z', + ts: '2026-01-01T00:00:00Z' }); } @@ -339,8 +342,8 @@ describe('ProjectionService', () => { type: 'signal.emitted', subject: { kind: 'signal', id: 'sig-new' }, data: { - decodedPayload: { signalType: 'new-signal' }, - }, + decodedPayload: { signalType: 'new-signal' } + } }); const result = service.applyEvents(base, [event]); @@ -366,9 +369,9 @@ describe('ProjectionService', () => { decodedPayload: { proposalId: 'prop-1', confidence: 0.8, - reason: 'Analysis complete', - }, - }, + reason: 'Analysis complete' + } + } }); const result = service.applyEvents(base, [event]); @@ -390,9 +393,9 @@ describe('ProjectionService', () => { decodedPayload: { action: 'step_up', outcome_positive: false, - commitmentId: 'commit-1', - }, - }, + commitmentId: 'commit-1' + } + } }); const result = service.applyEvents(base, [event]); @@ -407,8 +410,8 @@ describe('ProjectionService', () => { type: 'decision.finalized', subject: { kind: 'decision', id: 'dec-1' }, data: { - decodedPayload: { action: 'approve', commitmentId: 'commit-1' }, - }, + decodedPayload: { action: 'approve', commitmentId: 'commit-1' } + } }); const result = service.applyEvents(base, [event]); @@ -423,8 +426,8 @@ describe('ProjectionService', () => { type: 'decision.finalized', subject: { kind: 'decision', id: 'dec-1' }, data: { - decodedPayload: { action: 'declined', commitmentId: 'commit-1' }, - }, + decodedPayload: { action: 'declined', commitmentId: 'commit-1' } + } }); const result = service.applyEvents(base, [event]); @@ -439,8 +442,8 @@ describe('ProjectionService', () => { type: 'decision.finalized', subject: { kind: 'decision', id: 'dec-1' }, data: { - decodedPayload: { action: 'step_up', commitmentId: 'commit-1' }, - }, + decodedPayload: { action: 'step_up', commitmentId: 'commit-1' } + } }); const result = service.applyEvents(base, [event]); @@ -453,7 +456,7 @@ describe('ProjectionService', () => { const base = service.empty('run-1'); const event = makeEvent({ type: 'run.created', - data: { status: 'starting', decisionPrompt: 'Decide whether to approve the transaction' }, + data: { status: 'starting', decisionPrompt: 'Decide whether to approve the transaction' } }); const result = service.applyEvents(base, [event]); @@ -471,8 +474,8 @@ describe('ProjectionService', () => { data: { sender: 'proposer', messageType: 'Proposal', - decodedPayload: { proposalId: 'prop-1', option: 'Deploy feature X', rationale: 'ready to ship' }, - }, + decodedPayload: { proposalId: 'prop-1', option: 'Deploy feature X', rationale: 'ready to ship' } + } }), makeEvent({ type: 'proposal.updated', @@ -482,8 +485,8 @@ describe('ProjectionService', () => { data: { sender: 'evaluator', messageType: 'Evaluation', - decodedPayload: { proposalId: 'prop-1', recommendation: 'APPROVE', confidence: 0.9, reason: 'looks good' }, - }, + decodedPayload: { proposalId: 'prop-1', recommendation: 'APPROVE', confidence: 0.9, reason: 'looks good' } + } }), makeEvent({ type: 'proposal.updated', @@ -493,9 +496,9 @@ describe('ProjectionService', () => { data: { sender: 'voter', messageType: 'Vote', - decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE', reason: 'approved' }, - }, - }), + decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE', reason: 'approved' } + } + }) ]; const result = service.applyEvents(base, events); @@ -505,20 +508,20 @@ describe('ProjectionService', () => { participantId: 'proposer', action: 'Deploy feature X', reasons: ['ready to ship'], - messageType: 'Proposal', + messageType: 'Proposal' }); expect(result.decision.current?.proposals?.[1]).toMatchObject({ participantId: 'evaluator', action: 'APPROVE', vote: 'allow', confidence: 0.9, - messageType: 'Evaluation', + messageType: 'Evaluation' }); expect(result.decision.current?.proposals?.[2]).toMatchObject({ participantId: 'voter', action: 'APPROVE', vote: 'allow', - messageType: 'Vote', + messageType: 'Vote' }); }); @@ -530,8 +533,8 @@ describe('ProjectionService', () => { subject: { kind: 'decision', id: 'dec-1' }, data: { sender: 'system', - decodedPayload: { action: 'approve', commitmentId: 'commit-1' }, - }, + decodedPayload: { action: 'approve', commitmentId: 'commit-1' } + } }); const result = service.applyEvents(base, [event]); @@ -552,9 +555,9 @@ describe('ProjectionService', () => { action: 'approve', confidence: 1.0, reason: 'Consensus reached', - commitmentId: 'commit-1', - }, - }, + commitmentId: 'commit-1' + } + } }); const result = service.applyEvents(base, [event]); @@ -580,14 +583,14 @@ describe('ProjectionService', () => { type: 'artifact.created', seq: 1, subject: { kind: 'artifact', id: 'art-1' }, - data: {}, + data: {} }); const event2 = makeEvent({ type: 'artifact.created', id: 'evt-2', seq: 2, subject: { kind: 'artifact', id: 'art-2' }, - data: {}, + data: {} }); const result = service.applyEvents(base, [event1, event2]); @@ -611,8 +614,8 @@ describe('ProjectionService', () => { id: `evt-${i}`, seq: i, type: 'message.sent', - data: { sender: 'a', to: ['b'] }, - }), + data: { sender: 'a', to: ['b'] } + }) ); } @@ -638,7 +641,7 @@ describe('ProjectionService', () => { const event = makeEvent({ type: 'session.state.changed', - data: { sessionId: 'session-1', state: 'SESSION_STATE_RESOLVED' }, + data: { sessionId: 'session-1', state: 'SESSION_STATE_RESOLVED' } }); const result = service.applyEvents(base, [event]); @@ -653,7 +656,12 @@ describe('ProjectionService', () => { // ----------------------------------------------------------------------- describe('applyEvents — terminal participant sweep', () => { - function seed(base: RunStateProjection, id: string, status: 'idle' | 'active' | 'waiting', latestActivityAt?: string) { + function seed( + base: RunStateProjection, + id: string, + status: 'idle' | 'active' | 'waiting', + latestActivityAt?: string + ) { base.participants.push({ participantId: id, status, latestActivityAt }); base.graph.nodes.push({ id, kind: 'participant', status }); } @@ -665,9 +673,7 @@ describe('ProjectionService', () => { seed(base, 'agent-B', 'waiting', '2026-01-01T00:00:03Z'); seed(base, 'agent-C', 'idle'); - const result = service.applyEvents(base, [ - makeEvent({ type: 'run.completed', data: { status: 'completed' } }), - ]); + const result = service.applyEvents(base, [makeEvent({ type: 'run.completed', data: { status: 'completed' } })]); const byId = (id: string) => result.participants.find((p) => p.participantId === id)!; expect(byId('agent-A').status).toBe('completed'); @@ -688,7 +694,7 @@ describe('ProjectionService', () => { seed(base, 'agent-C', 'idle'); const result = service.applyEvents(base, [ - makeEvent({ type: 'run.failed', data: { status: 'failed', error: 'boom' } }), + makeEvent({ type: 'run.failed', data: { status: 'failed', error: 'boom' } }) ]); const byId = (id: string) => result.participants.find((p) => p.participantId === id)!; @@ -703,9 +709,7 @@ describe('ProjectionService', () => { seed(base, 'agent-A', 'active', '2026-01-01T00:00:05Z'); seed(base, 'agent-B', 'idle'); - const result = service.applyEvents(base, [ - makeEvent({ type: 'run.cancelled', data: { status: 'cancelled' } }), - ]); + const result = service.applyEvents(base, [makeEvent({ type: 'run.cancelled', data: { status: 'cancelled' } })]); const byId = (id: string) => result.participants.find((p) => p.participantId === id)!; expect(byId('agent-A').status).toBe('completed'); @@ -719,9 +723,7 @@ describe('ProjectionService', () => { base.graph.nodes.push({ id: 'agent-A', kind: 'participant', status: 'failed' }); seed(base, 'agent-B', 'active', '2026-01-01T00:00:05Z'); - const result = service.applyEvents(base, [ - makeEvent({ type: 'run.completed', data: { status: 'completed' } }), - ]); + const result = service.applyEvents(base, [makeEvent({ type: 'run.completed', data: { status: 'completed' } })]); expect(result.participants.find((p) => p.participantId === 'agent-A')!.status).toBe('failed'); expect(result.participants.find((p) => p.participantId === 'agent-B')!.status).toBe('completed'); @@ -736,8 +738,8 @@ describe('ProjectionService', () => { const result = service.applyEvents(base, [ makeEvent({ type: 'session.state.changed', - data: { sessionId: 'session-1', state: 'SESSION_STATE_RESOLVED' }, - }), + data: { sessionId: 'session-1', state: 'SESSION_STATE_RESOLVED' } + }) ]); expect(result.participants.find((p) => p.participantId === 'agent-A')!.status).toBe('completed'); @@ -753,8 +755,8 @@ describe('ProjectionService', () => { const result = service.applyEvents(base, [ makeEvent({ type: 'session.state.changed', - data: { sessionId: 'session-1', state: 'SESSION_STATE_EXPIRED' }, - }), + data: { sessionId: 'session-1', state: 'SESSION_STATE_EXPIRED' } + }) ]); expect(result.participants.find((p) => p.participantId === 'agent-A')!.status).toBe('failed'); @@ -778,7 +780,7 @@ describe('ProjectionService', () => { const base = service.empty('run-1'); const event = makeEvent({ type: 'run.created', - data: { status: 'starting' }, + data: { status: 'starting' } }); const result = service.applyEvents(base, [event]); @@ -804,7 +806,7 @@ describe('ProjectionService', () => { const event = makeEvent({ type: 'run.created', seq: 1, - data: { status: 'starting' }, + data: { status: 'starting' } }); const result = await service.applyAndPersist('run-1', [event]); @@ -827,8 +829,8 @@ describe('ProjectionService', () => { type: 'participant.seen', id: 'evt-2', seq: 2, - data: { participantId: 'agent-A' }, - }), + data: { participantId: 'agent-A' } + }) ]; const result = await service.replayStateAt('run-1', events); @@ -849,7 +851,7 @@ describe('ProjectionService', () => { const event = makeEvent({ type: 'message.sent', data: { sender: 'a', to: ['b'] }, - trace: { traceId: 'trace-1', spanId: 'span-1' }, + trace: { traceId: 'trace-1', spanId: 'span-1' } }); const result = service.applyEvents(base, [event]); @@ -876,7 +878,7 @@ describe('ProjectionService', () => { policyVersion: 'policy.fraud.majority', description: 'Majority veto policy' } - }, + } }); const result = service.applyEvents(base, [event]); @@ -897,7 +899,7 @@ describe('ProjectionService', () => { decision: 'allow', reasons: ['quorum met', 'no blocking objections'] } - }, + } }); const result = service.applyEvents(base, [event]); @@ -922,16 +924,14 @@ describe('ProjectionService', () => { decision: 'deny', reasons: ['voting threshold not met: 1 of 3 required'] } - }, + } }); const result = service.applyEvents(base, [event]); expect(result.policy.commitmentEvaluations).toHaveLength(1); expect(result.policy.commitmentEvaluations[0].decision).toBe('deny'); - expect(result.policy.commitmentEvaluations[0].reasons).toEqual([ - 'voting threshold not met: 1 of 3 required' - ]); + expect(result.policy.commitmentEvaluations[0].reasons).toEqual(['voting threshold not met: 1 of 3 required']); }); it('commitmentEvaluations capped at 50 entries', () => { @@ -953,7 +953,7 @@ describe('ProjectionService', () => { decision: 'deny', reasons: ['cap test'] } - }, + } }); const result = service.applyEvents(base, [event]); @@ -977,9 +977,9 @@ describe('ProjectionService', () => { completionTokens: 45, totalTokens: 165, latencyMs: 890, - estimatedCostUsd: 0.00042, - }, - }, + estimatedCostUsd: 0.00042 + } + } }); const result = service.applyEvents(base, [event]); @@ -992,14 +992,14 @@ describe('ProjectionService', () => { completionTokens: 45, totalTokens: 165, latencyMs: 890, - messageId: 'msg-1', + messageId: 'msg-1' }); expect(result.llm.totals).toEqual({ callCount: 1, promptTokens: 120, completionTokens: 45, totalTokens: 165, - estimatedCostUsd: 0.00042, + estimatedCostUsd: 0.00042 }); }); @@ -1011,12 +1011,12 @@ describe('ProjectionService', () => { promptTokens: 1, completionTokens: 1, totalTokens: 2, - ts: '2026-04-14T00:00:00Z', + ts: '2026-04-14T00:00:00Z' }); } const event = makeEvent({ type: 'llm.call.completed', - data: { sender: 'new-agent', decodedPayload: { promptTokens: 5, completionTokens: 5 } }, + data: { sender: 'new-agent', decodedPayload: { promptTokens: 5, completionTokens: 5 } } }); const result = service.applyEvents(base, [event]); @@ -1043,15 +1043,19 @@ describe('ProjectionService', () => { sessionId: 'session-1', expectedCommitments: [ { id: 'commit-approve', title: 'Approve', requiredRoles: ['voter'] }, - { id: 'commit-reject', title: 'Reject', requiredRoles: ['voter'] }, - ], - }, + { id: 'commit-reject', title: 'Reject', requiredRoles: ['voter'] } + ] + } }); const result = service.applyEvents(base, [event]); expect(result.policy.expectedCommitments).toHaveLength(2); - expect(result.policy.expectedCommitments?.[0]).toEqual({ id: 'commit-approve', title: 'Approve', requiredRoles: ['voter'] }); + expect(result.policy.expectedCommitments?.[0]).toEqual({ + id: 'commit-approve', + title: 'Approve', + requiredRoles: ['voter'] + }); expect(result.policy.quorumStatus).toBe('pending'); }); @@ -1070,8 +1074,8 @@ describe('ProjectionService', () => { data: { sender: 'voter-a', messageType: 'Vote', - decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE' }, - }, + decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE' } + } }), makeEvent({ type: 'proposal.updated', @@ -1080,8 +1084,8 @@ describe('ProjectionService', () => { data: { sender: 'voter-b', messageType: 'Vote', - decodedPayload: { proposalId: 'prop-1', vote: 'REJECT' }, - }, + decodedPayload: { proposalId: 'prop-1', vote: 'REJECT' } + } }), makeEvent({ type: 'proposal.updated', @@ -1090,9 +1094,9 @@ describe('ProjectionService', () => { data: { sender: 'voter-c', messageType: 'Vote', - decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE' }, - }, - }), + decodedPayload: { proposalId: 'prop-1', vote: 'APPROVE' } + } + }) ]; const result = service.applyEvents(base, events); @@ -1104,7 +1108,7 @@ describe('ProjectionService', () => { allow: 2, deny: 1, threshold: 2, - quorum: { required: 3, cast: 3 }, + quorum: { required: 3, cast: 3 } }); expect(result.policy.quorumStatus).toBe('pending'); }); @@ -1115,8 +1119,8 @@ describe('ProjectionService', () => { type: 'policy.commitment.evaluated', subject: { kind: 'policy', id: 'commit-1' }, data: { - decodedPayload: { commitmentId: 'commit-1', decision: 'allow', reasons: ['ok'] }, - }, + decodedPayload: { commitmentId: 'commit-1', decision: 'allow', reasons: ['ok'] } + } }); const result = service.applyEvents(base, [event]); @@ -1130,7 +1134,7 @@ describe('ProjectionService', () => { base.policy.quorumStatus = 'pending'; const result = service.applyEvents(base, [ - makeEvent({ type: 'run.failed', data: { status: 'failed', error: 'boom' } }), + makeEvent({ type: 'run.failed', data: { status: 'failed', error: 'boom' } }) ]); expect(result.policy.quorumStatus).toBe('failed'); diff --git a/src/projection/projection.service.ts b/src/projection/projection.service.ts index 43c633a..185571b 100644 --- a/src/projection/projection.service.ts +++ b/src/projection/projection.service.ts @@ -26,7 +26,9 @@ export class ProjectionService { // Schema version migration: if stored schema is older, mark as needing rebuild const storedSchemaVersion = (row as unknown as Record).schemaVersion as number | undefined; if (storedSchemaVersion != null && storedSchemaVersion < PROJECTION_SCHEMA_VERSION) { - this.logger.warn(`projection for run ${runId} has schema v${storedSchemaVersion}, current is v${PROJECTION_SCHEMA_VERSION} — returning stale data (rebuild recommended)`); + this.logger.warn( + `projection for run ${runId} has schema v${storedSchemaVersion}, current is v${PROJECTION_SCHEMA_VERSION} — returning stale data (rebuild recommended)` + ); } return { @@ -35,12 +37,23 @@ export class ProjectionService { graph: row.graph as unknown as GraphProjection, decision: row.decision as unknown as RunStateProjection['decision'], signals: row.signals as unknown as RunStateProjection['signals'], - progress: row.progress as unknown as ProgressProjection ?? { entries: [] }, + progress: (row.progress as unknown as ProgressProjection) ?? { entries: [] }, timeline: row.timeline as unknown as RunStateProjection['timeline'], trace: row.traceSummary as unknown as RunStateProjection['trace'], - outboundMessages: (row as unknown as Record).outboundMessages as OutboundMessageSummary ?? { total: 0, queued: 0, accepted: 0, rejected: 0 }, - policy: (row as unknown as Record).policy as RunStateProjection['policy'] ?? { policyVersion: '', commitmentEvaluations: [] }, - llm: (row as unknown as Record).llm as RunStateProjection['llm'] ?? { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } } + outboundMessages: ((row as unknown as Record).outboundMessages as OutboundMessageSummary) ?? { + total: 0, + queued: 0, + accepted: 0, + rejected: 0 + }, + policy: ((row as unknown as Record).policy as RunStateProjection['policy']) ?? { + policyVersion: '', + commitmentEvaluations: [] + }, + llm: ((row as unknown as Record).llm as RunStateProjection['llm']) ?? { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }; } @@ -48,7 +61,13 @@ export class ProjectionService { const current = (await this.get(runId)) ?? this.empty(runId); const next = this.applyEvents(current, events); const version = (events.at(-1)?.seq ?? current.timeline.latestSeq) || 0; - await this.projectionRepository.upsert(runId, next, version, PROJECTION_SCHEMA_VERSION, tx as Parameters[4]); + await this.projectionRepository.upsert( + runId, + next, + version, + PROJECTION_SCHEMA_VERSION, + tx as Parameters[4] + ); return next; } @@ -64,13 +83,16 @@ export class ProjectionService { for (const event of events) { next.timeline.latestSeq = event.seq; next.timeline.totalEvents += 1; - next.timeline.recent = [...next.timeline.recent, { - id: event.id, - seq: event.seq, - ts: event.ts, - type: event.type, - subject: event.subject - }].slice(-50); + next.timeline.recent = [ + ...next.timeline.recent, + { + id: event.id, + seq: event.seq, + ts: event.ts, + type: event.type, + subject: event.subject + } + ].slice(-50); if (event.trace?.traceId) { next.trace.traceId = event.trace.traceId; @@ -118,8 +140,15 @@ export class ProjectionService { case 'session.bound': case 'session.state.changed': { next.run.runtimeSessionId = (event.data.sessionId as string | undefined) ?? next.run.runtimeSessionId; + if (typeof event.data.contextId === 'string') { + next.run.contextId = event.data.contextId; + } + if (Array.isArray(event.data.extensionKeys)) { + next.run.extensionKeys = event.data.extensionKeys as string[]; + } if (event.type === 'session.bound' && Array.isArray(event.data.expectedCommitments)) { - next.policy.expectedCommitments = event.data.expectedCommitments as RunStateProjection['policy']['expectedCommitments']; + next.policy.expectedCommitments = event.data + .expectedCommitments as RunStateProjection['policy']['expectedCommitments']; if (next.policy.expectedCommitments && next.policy.expectedCommitments.length > 0) { next.policy.quorumStatus = next.policy.quorumStatus ?? 'pending'; } @@ -202,12 +231,7 @@ export class ProjectionService { } case 'signal.acknowledged': { const ackPayload = event.data.decodedPayload as Record | undefined; - const targetSignalId = String( - ackPayload?.signalId ?? - ackPayload?.signal_id ?? - event.subject?.id ?? - '' - ); + const targetSignalId = String(ackPayload?.signalId ?? ackPayload?.signal_id ?? event.subject?.id ?? ''); const acknowledger = (event.data.sender as string | undefined) ?? undefined; if (targetSignalId) { const signal = next.signals.signals.find((s) => s.id === targetSignalId); @@ -233,12 +257,16 @@ export class ProjectionService { messageType: messageType || undefined }; const existingProposals = next.decision.current?.proposals ?? []; - const proposalId = String(proposalPayload?.proposalId ?? proposalPayload?.requestId ?? event.subject?.id ?? ''); + const proposalId = String( + proposalPayload?.proposalId ?? proposalPayload?.requestId ?? event.subject?.id ?? '' + ); next.decision.current = { ...(next.decision.current ?? { finalized: false }), action: proposalId || String(event.subject?.id ?? 'proposal'), confidence: safeOptionalNumber(proposalPayload?.confidence) ?? next.decision.current?.confidence, - reasons: [String(proposalPayload?.reason ?? proposalPayload?.summary ?? proposalPayload?.rationale ?? event.type)].filter(Boolean), + reasons: [ + String(proposalPayload?.reason ?? proposalPayload?.summary ?? proposalPayload?.rationale ?? event.type) + ].filter(Boolean), finalized: false, proposalId, proposals: [...existingProposals, contribution].slice(-50) @@ -255,9 +283,7 @@ export class ProjectionService { const action = String(payload?.action ?? 'resolved'); const explicitOutcome = payload?.outcomePositive ?? payload?.outcome_positive; const outcomePositive: boolean | null = - explicitOutcome != null - ? Boolean(explicitOutcome) - : inferOutcomePositiveFromAction(action); + explicitOutcome != null ? Boolean(explicitOutcome) : inferOutcomePositiveFromAction(action); const sender = (event.data.sender as string | undefined) ?? undefined; next.decision.current = { ...(next.decision.current ?? { finalized: false }), @@ -307,7 +333,10 @@ export class ProjectionService { const completion = safeOptionalNumber(payload.completionTokens) ?? 0; const total = safeOptionalNumber(payload.totalTokens) ?? prompt + completion; if (!next.llm) { - next.llm = { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }; + next.llm = { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + }; } next.llm.calls = [ ...next.llm.calls, @@ -321,7 +350,7 @@ export class ProjectionService { ts: event.ts, messageId: event.data.messageId as string | undefined, artifactId: payload.artifactId as string | undefined, - estimatedCostUsd: safeOptionalNumber(payload.estimatedCostUsd), + estimatedCostUsd: safeOptionalNumber(payload.estimatedCostUsd) } ].slice(-100); next.llm.totals.callCount += 1; @@ -382,7 +411,10 @@ export class ProjectionService { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } } + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }; } @@ -426,9 +458,7 @@ export class ProjectionService { if (outcome === 'failed') { const withActivity = toSweep.filter((p) => p.latestActivityAt); if (withActivity.length > 0) { - lastActive = withActivity.reduce((a, b) => - (a.latestActivityAt ?? '') > (b.latestActivityAt ?? '') ? a : b - ); + lastActive = withActivity.reduce((a, b) => ((a.latestActivityAt ?? '') > (b.latestActivityAt ?? '') ? a : b)); } } @@ -486,7 +516,13 @@ function inferContributionVote(messageType: string, payload?: Record & { seq: number }): CanonicalR traceId: overrides.traceId ?? null, spanId: overrides.spanId ?? null, parentSpanId: overrides.parentSpanId ?? null, - data: overrides.data ?? { sender: 'a', to: ['b'] }, + data: overrides.data ?? { sender: 'a', to: ['b'] } }; } @@ -51,16 +51,16 @@ function makeRow(overrides: Partial & { seq: number }): CanonicalR const mockEventRepository: jest.Mocked> = { listCanonicalByRun: jest.fn(), - listCanonicalUpTo: jest.fn(), + listCanonicalUpTo: jest.fn() }; const mockProjectionService: jest.Mocked> = { - replayStateAt: jest.fn(), + replayStateAt: jest.fn() }; const mockConfig: Pick = { replayBatchSize: 500, - replayMaxDelayMs: 2000, + replayMaxDelayMs: 2000 }; // --------------------------------------------------------------------------- @@ -75,7 +75,7 @@ describe('ReplayService', () => { service = new ReplayService( mockEventRepository as unknown as EventRepository, mockProjectionService as unknown as ProjectionService, - mockConfig as AppConfigService, + mockConfig as AppConfigService ); }); @@ -89,7 +89,7 @@ describe('ReplayService', () => { mode: 'timed', speed: 2, fromSeq: 5, - toSeq: 100, + toSeq: 100 }); expect(descriptor).toEqual({ @@ -99,7 +99,7 @@ describe('ReplayService', () => { fromSeq: 5, toSeq: 100, streamUrl: '/runs/run-1/replay/stream?mode=timed&speed=2', - stateUrl: '/runs/run-1/replay/state', + stateUrl: '/runs/run-1/replay/state' }); }); @@ -159,7 +159,7 @@ describe('ReplayService', () => { it('emits with delays between events', async () => { const rows = [ makeRow({ seq: 1, ts: '2026-01-01T00:00:00.000Z' }), - makeRow({ seq: 2, ts: '2026-01-01T00:00:00.100Z' }), // 100ms later + makeRow({ seq: 2, ts: '2026-01-01T00:00:00.100Z' }) // 100ms later ]; mockEventRepository.listCanonicalByRun.mockResolvedValue(rows as any); @@ -186,7 +186,7 @@ describe('ReplayService', () => { const observable = service.stream('run-1', { mode: 'instant', fromSeq: 5, - toSeq: 6, + toSeq: 6 }); const emissions = await firstValueFrom(observable.pipe(toArray())); @@ -235,7 +235,10 @@ describe('ReplayService', () => { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }, + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }; mockProjectionService.replayStateAt.mockResolvedValue(fakeProjection); @@ -244,10 +247,7 @@ describe('ReplayService', () => { expect(mockEventRepository.listCanonicalUpTo).toHaveBeenCalledWith('run-1', 2); expect(mockProjectionService.replayStateAt).toHaveBeenCalledWith( 'run-1', - expect.arrayContaining([ - expect.objectContaining({ seq: 1 }), - expect.objectContaining({ seq: 2 }), - ]), + expect.arrayContaining([expect.objectContaining({ seq: 1 }), expect.objectContaining({ seq: 2 })]) ); expect(result).toEqual(fakeProjection); }); @@ -264,7 +264,7 @@ describe('ReplayService', () => { const paginatedService = new ReplayService( mockEventRepository as unknown as EventRepository, mockProjectionService as unknown as ProjectionService, - smallBatchConfig as AppConfigService, + smallBatchConfig as AppConfigService ); // First call returns full batch (2 rows), second call returns partial (1 row) diff --git a/src/replay/replay.service.ts b/src/replay/replay.service.ts index 91a04a7..699fff6 100644 --- a/src/replay/replay.service.ts +++ b/src/replay/replay.service.ts @@ -105,10 +105,18 @@ export class ReplayService { seq: row.seq, ts: row.ts, type: row.type, - subject: row.subjectKind && row.subjectId - ? { kind: row.subjectKind as CanonicalEvent['subject'] extends { kind: infer K } ? K : never, id: row.subjectId } - : undefined, - source: { kind: row.sourceKind as 'runtime' | 'control-plane' | 'replay', name: row.sourceName, rawType: row.rawType ?? undefined }, + subject: + row.subjectKind && row.subjectId + ? { + kind: row.subjectKind as CanonicalEvent['subject'] extends { kind: infer K } ? K : never, + id: row.subjectId + } + : undefined, + source: { + kind: row.sourceKind as 'runtime' | 'control-plane' | 'replay', + name: row.sourceName, + rawType: row.rawType ?? undefined + }, trace: { traceId: row.traceId ?? undefined, spanId: row.spanId ?? undefined, diff --git a/src/retention/data-retention.service.spec.ts b/src/retention/data-retention.service.spec.ts index 4eeefca..e7d7492 100644 --- a/src/retention/data-retention.service.spec.ts +++ b/src/retention/data-retention.service.spec.ts @@ -14,13 +14,13 @@ describe('DataRetentionService', () => { const makeSelectChain = (rows: unknown[]) => ({ from: jest.fn().mockReturnValue({ where: jest.fn().mockReturnValue({ - limit: jest.fn().mockResolvedValue(rows), - }), - }), + limit: jest.fn().mockResolvedValue(rows) + }) + }) }); const makeDeleteChain = (rowCount: number) => ({ - where: jest.fn().mockResolvedValue({ rowCount }), + where: jest.fn().mockResolvedValue({ rowCount }) }); beforeEach(() => { @@ -28,23 +28,23 @@ describe('DataRetentionService', () => { dataRetentionEnabled: false, dataRetentionTtlDays: 30, dataRetentionIntervalHours: 24, - dataRetentionBatchSize: 500, + dataRetentionBatchSize: 500 }; mockDb = { select: jest.fn(), - delete: jest.fn(), + delete: jest.fn() }; mockDatabase = { db: mockDb as unknown as DatabaseService['db'], tryAdvisoryLock: jest.fn().mockResolvedValue(true), - advisoryUnlock: jest.fn().mockResolvedValue(undefined), + advisoryUnlock: jest.fn().mockResolvedValue(undefined) }; service = new DataRetentionService( mockConfig as unknown as AppConfigService, - mockDatabase as unknown as DatabaseService, + mockDatabase as unknown as DatabaseService ); }); @@ -96,9 +96,7 @@ describe('DataRetentionService', () => { // No runs to purge mockDb.select.mockReturnValue(makeSelectChain([])); // Audit log delete returns 5, webhook deliveries returns 3 - mockDb.delete - .mockReturnValueOnce(makeDeleteChain(5)) - .mockReturnValueOnce(makeDeleteChain(3)); + mockDb.delete.mockReturnValueOnce(makeDeleteChain(5)).mockReturnValueOnce(makeDeleteChain(3)); const result = await service.runRetention(); @@ -127,10 +125,10 @@ describe('DataRetentionService', () => { .mockReturnValueOnce(makeSelectChain(batch2)) .mockReturnValueOnce(makeSelectChain([])); mockDb.delete - .mockReturnValueOnce(makeDeleteChain(500)) // first batch of runs - .mockReturnValueOnce(makeDeleteChain(1)) // second batch of runs - .mockReturnValueOnce(makeDeleteChain(0)) // audit logs - .mockReturnValueOnce(makeDeleteChain(0)); // webhook deliveries + .mockReturnValueOnce(makeDeleteChain(500)) // first batch of runs + .mockReturnValueOnce(makeDeleteChain(1)) // second batch of runs + .mockReturnValueOnce(makeDeleteChain(0)) // audit logs + .mockReturnValueOnce(makeDeleteChain(0)); // webhook deliveries const result = await service.runRetention(); diff --git a/src/retention/data-retention.service.ts b/src/retention/data-retention.service.ts index 2730645..53225c4 100644 --- a/src/retention/data-retention.service.ts +++ b/src/retention/data-retention.service.ts @@ -13,7 +13,7 @@ export class DataRetentionService implements OnModuleInit, OnModuleDestroy { constructor( private readonly config: AppConfigService, - private readonly database: DatabaseService, + private readonly database: DatabaseService ) {} onModuleInit(): void { @@ -24,7 +24,7 @@ export class DataRetentionService implements OnModuleInit, OnModuleDestroy { const intervalMs = this.config.dataRetentionIntervalHours * 60 * 60 * 1000; this.logger.log( - `Data retention enabled: TTL=${this.config.dataRetentionTtlDays}d, interval=${this.config.dataRetentionIntervalHours}h, batch=${this.config.dataRetentionBatchSize}`, + `Data retention enabled: TTL=${this.config.dataRetentionTtlDays}d, interval=${this.config.dataRetentionIntervalHours}h, batch=${this.config.dataRetentionBatchSize}` ); // Run once at startup (after a short delay to let app stabilize), then on interval @@ -63,7 +63,7 @@ export class DataRetentionService implements OnModuleInit, OnModuleDestroy { const deletedWebhookDeliveries = await this.purgeWebhookDeliveries(cutoff); this.logger.log( - `Retention complete: ${deletedRuns} runs, ${deletedAuditLogs} audit logs, ${deletedWebhookDeliveries} webhook deliveries purged`, + `Retention complete: ${deletedRuns} runs, ${deletedAuditLogs} audit logs, ${deletedWebhookDeliveries} webhook deliveries purged` ); return { deletedRuns, deletedAuditLogs, deletedWebhookDeliveries }; @@ -88,20 +88,13 @@ export class DataRetentionService implements OnModuleInit, OnModuleDestroy { const staleIds = await this.database.db .select({ id: runs.id }) .from(runs) - .where( - and( - inArray(runs.status, TERMINAL_STATUSES), - lt(runs.endedAt, cutoff), - ), - ) + .where(and(inArray(runs.status, TERMINAL_STATUSES), lt(runs.endedAt, cutoff))) .limit(this.config.dataRetentionBatchSize); if (staleIds.length === 0) break; const ids = staleIds.map((r) => r.id); - const result = await this.database.db - .delete(runs) - .where(inArray(runs.id, ids)); + const result = await this.database.db.delete(runs).where(inArray(runs.id, ids)); deleted = (result as unknown as { rowCount: number }).rowCount ?? ids.length; total += deleted; @@ -113,17 +106,13 @@ export class DataRetentionService implements OnModuleInit, OnModuleDestroy { } private async purgeAuditLogs(cutoff: string): Promise { - const result = await this.database.db - .delete(auditLog) - .where(lt(auditLog.createdAt, cutoff)); + const result = await this.database.db.delete(auditLog).where(lt(auditLog.createdAt, cutoff)); return (result as unknown as { rowCount: number }).rowCount ?? 0; } private async purgeWebhookDeliveries(cutoff: string): Promise { - const result = await this.database.db - .delete(webhookDeliveries) - .where(lt(webhookDeliveries.createdAt, cutoff)); + const result = await this.database.db.delete(webhookDeliveries).where(lt(webhookDeliveries.createdAt, cutoff)); return (result as unknown as { rowCount: number }).rowCount ?? 0; } diff --git a/src/runs/run-executor.service.spec.ts b/src/runs/run-executor.service.spec.ts index 060b6c3..443d256 100644 --- a/src/runs/run-executor.service.spec.ts +++ b/src/runs/run-executor.service.spec.ts @@ -29,9 +29,9 @@ function makeRunDescriptor(overrides: Partial = {}): RunDescripto modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }, { id: 'agent-2' }], + participants: [{ id: 'agent-1' }, { id: 'agent-2' }] }, - ...overrides, + ...overrides }; } @@ -43,14 +43,14 @@ function makeRun(overrides: Partial = {}): Run { runtimeSessionId: 'sess-1', createdAt: new Date().toISOString(), metadata: { executionRequest: makeRunDescriptor() }, - ...overrides, + ...overrides }; } function makeReadOnlyHandle(): RuntimeSessionHandle { return { events: (async function* () {})(), - abort: jest.fn(), + abort: jest.fn() }; } @@ -125,17 +125,17 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { runtimeInfo: { name: 'rust-runtime', version: '0.3.0' }, supportedModes: ['decision', 'proposal', 'task'], capabilities: {}, - instructions: undefined, + instructions: undefined }), subscribeSession: jest.fn().mockReturnValue(makeReadOnlyHandle()), getSession: jest.fn().mockResolvedValue({ sessionId: 'sess-1', mode: 'decision', state: 'SESSION_STATE_OPEN', - initiator: 'agent-1', + initiator: 'agent-1' }), cancelSession: jest.fn().mockResolvedValue({ - ack: { ok: true, sessionState: 'SESSION_STATE_RESOLVED' }, + ack: { ok: true, sessionState: 'SESSION_STATE_RESOLVED' } }), getManifest: jest.fn(), listModes: jest.fn(), @@ -144,7 +144,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { registerPolicy: jest.fn(), unregisterPolicy: jest.fn(), getPolicy: jest.fn(), - listPolicies: jest.fn(), + listPolicies: jest.fn() }; mockRunManager = { @@ -154,42 +154,42 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { markCancelled: jest.fn().mockResolvedValue(makeRun({ status: 'cancelled' })), markRunning: jest.fn().mockResolvedValue(makeRun({ status: 'running' })), getRun: jest.fn().mockResolvedValue(makeRun()), - bindSession: jest.fn().mockResolvedValue(makeRun({ status: 'binding_session' })), + bindSession: jest.fn().mockResolvedValue(makeRun({ status: 'binding_session' })) }; mockRuntimeSessionRepository = { findByRunId: jest.fn().mockResolvedValue({ modeName: 'decision', - initiatorParticipantId: 'agent-1', - }), + initiatorParticipantId: 'agent-1' + }) }; mockRuntimeRegistry = { - get: jest.fn().mockReturnValue(mockProvider), + get: jest.fn().mockReturnValue(mockProvider) }; mockTraceService = { withSpan: jest.fn().mockImplementation((_name, _attrs, fn) => fn()), withRunSpan: jest.fn().mockImplementation((_runId, _name, _attrs, fn) => fn()), addRunSpanEvent: jest.fn(), - getRunTraceContext: jest.fn().mockReturnValue(undefined), + getRunTraceContext: jest.fn().mockReturnValue(undefined) }; mockEventService = { - emitControlPlaneEvents: jest.fn().mockResolvedValue(undefined), + emitControlPlaneEvents: jest.fn().mockResolvedValue(undefined) }; mockArtifactService = { - register: jest.fn().mockResolvedValue({ id: 'art-1', kind: 'trace', label: 'Root run trace' }), + register: jest.fn().mockResolvedValue({ id: 'art-1', kind: 'trace', label: 'Root run trace' }) }; mockStreamConsumer = { start: jest.fn().mockResolvedValue(undefined), - stop: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined) }; mockStreamHub = { - complete: jest.fn(), + complete: jest.fn() }; mockConfig = { @@ -198,7 +198,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { sessionPollBaseMs: 10, sessionPollMaxMs: 50, sessionPollTimeoutMs: 1000, - cancelCallbackTimeoutMs: 5000, + cancelCallbackTimeoutMs: 5000 }; service = new RunExecutorService( @@ -212,7 +212,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { mockStreamConsumer as unknown as StreamConsumerService, mockStreamHub as unknown as StreamHubService, mockConfig as unknown as AppConfigService, - { outboundMessagesTotal: { inc: jest.fn() } } as unknown as InstrumentationService, + { outboundMessagesTotal: { inc: jest.fn() } } as unknown as InstrumentationService ); }); @@ -227,16 +227,14 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [], - }, + participants: [] + } }); const result = await service.validate(request); expect(result.valid).toBe(false); - expect(result.errors).toContain( - 'session.participants must contain at least one participant', - ); + expect(result.errors).toContain('session.participants must contain at least one participant'); }); it('returns error when modeName is missing', async () => { @@ -246,8 +244,8 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }); const result = await service.validate(request); @@ -261,16 +259,14 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { selectedProtocolVersion: '1.0', runtimeInfo: { name: 'rust-runtime' }, supportedModes: ['task', 'proposal'], - capabilities: {}, + capabilities: {} }); const result = await service.validate(makeRunDescriptor()); expect(result.valid).toBe(false); expect(result.errors).toEqual( - expect.arrayContaining([ - expect.stringContaining("does not support mode 'decision'"), - ]), + expect.arrayContaining([expect.stringContaining("does not support mode 'decision'")]) ); }); @@ -289,14 +285,12 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { configurationVersion: '1.0', ttlMs: 60000, participants: [{ id: 'agent-1' }], - sessionId: 'bad-id', - }, - }), + sessionId: 'bad-id' + } + }) ); expect(result.valid).toBe(false); - expect(result.errors).toContain( - 'session.sessionId must be a UUID v4/v7 or base64url 22+ chars', - ); + expect(result.errors).toContain('session.sessionId must be a UUID v4/v7 or base64url 22+ chars'); }); it('returns warning (not error) when runtime is unreachable', async () => { @@ -305,9 +299,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { const result = await service.validate(makeRunDescriptor()); expect(result.valid).toBe(true); - expect(result.warnings).toEqual( - expect.arrayContaining([expect.stringContaining('Runtime not reachable')]), - ); + expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining('Runtime not reachable')])); expect(result.runtime.reachable).toBe(false); }); }); @@ -326,9 +318,9 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(result.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-/); expect(mockRunManager.createRun).toHaveBeenCalledWith( expect.objectContaining({ - session: expect.objectContaining({ sessionId: result.sessionId }), + session: expect.objectContaining({ sessionId: result.sessionId }) }), - result.sessionId, + result.sessionId ); }); @@ -345,9 +337,9 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { configurationVersion: '1.0', ttlMs: 60000, participants: [{ id: 'agent-1' }], - sessionId, - }, - }), + sessionId + } + }) ); expect(result.sessionId).toBe(sessionId); @@ -363,10 +355,10 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { configurationVersion: '1.0', ttlMs: 60000, participants: [{ id: 'agent-1' }], - sessionId: 'too-short', - }, - }), - ), + sessionId: 'too-short' + } + }) + ) ).rejects.toThrow(BadRequestException); }); @@ -395,8 +387,8 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { runtimeSessionId: 'sess-1', metadata: { executionRequest: makeRunDescriptor(), - cancelCallback: { url: 'http://agent/cancel', bearer: 'tok' }, - }, + cancelCallback: { url: 'http://agent/cancel', bearer: 'tok' } + } }); mockRunManager.getRun.mockResolvedValue(run); @@ -408,10 +400,10 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { method: 'POST', headers: expect.objectContaining({ 'content-type': 'application/json', - authorization: 'Bearer tok', + authorization: 'Bearer tok' }), - body: expect.stringContaining('"runId":"run-1"'), - }), + body: expect.stringContaining('"runId":"run-1"') + }) ); expect(mockProvider.cancelSession).not.toHaveBeenCalled(); expect(mockRunManager.markCancelled).toHaveBeenCalledWith('run-1'); @@ -424,8 +416,8 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { runtimeSessionId: 'sess-1', metadata: { executionRequest: makeRunDescriptor(), - cancellationDelegated: true, - }, + cancellationDelegated: true + } }); mockRunManager.getRun.mockResolvedValue(run); @@ -434,7 +426,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockProvider.cancelSession).toHaveBeenCalledWith({ runId: 'run-1', runtimeSessionId: 'sess-1', - reason: 'policy-delegated', + reason: 'policy-delegated' }); expect(mockRunManager.markCancelled).toHaveBeenCalledWith('run-1'); }); @@ -443,7 +435,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { const run = makeRun({ status: 'running', runtimeSessionId: 'sess-1', - metadata: { executionRequest: makeRunDescriptor() }, + metadata: { executionRequest: makeRunDescriptor() } }); mockRunManager.getRun.mockResolvedValue(run); @@ -466,8 +458,8 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { runtimeSessionId: 'sess-1', metadata: { executionRequest: makeRunDescriptor(), - cancelCallback: { url: 'http://agent/cancel' }, - }, + cancelCallback: { url: 'http://agent/cancel' } + } }); mockRunManager.getRun.mockResolvedValue(run); @@ -487,9 +479,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { it('clones with tag overrides and allocates a fresh sessionId', async () => { const originalRequest = makeRunDescriptor(); - mockRunManager.getRun.mockResolvedValue( - makeRun({ metadata: { executionRequest: originalRequest } }), - ); + mockRunManager.getRun.mockResolvedValue(makeRun({ metadata: { executionRequest: originalRequest } })); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-2', status: 'queued' })); const result = await service.clone('run-1', { tags: ['cloned'] }); @@ -498,9 +488,9 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockRunManager.createRun).toHaveBeenCalledWith( expect.objectContaining({ execution: expect.objectContaining({ tags: ['cloned'] }), - session: expect.objectContaining({ sessionId: result.sessionId }), + session: expect.objectContaining({ sessionId: result.sessionId }) }), - result.sessionId, + result.sessionId ); }); @@ -513,12 +503,10 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { configurationVersion: '1.0', ttlMs: 60000, participants: [{ id: 'agent-1' }], - sessionId: 'original-session-id-that-would-be-valid-base64url', - }, + sessionId: 'original-session-id-that-would-be-valid-base64url' + } }); - mockRunManager.getRun.mockResolvedValue( - makeRun({ metadata: { executionRequest: originalRequest } }), - ); + mockRunManager.getRun.mockResolvedValue(makeRun({ metadata: { executionRequest: originalRequest } })); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-2' })); const result = await service.clone('run-1'); @@ -539,7 +527,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { selectedProtocolVersion: '1.0', runtimeInfo: { name: 'rust-runtime' }, supportedModes: ['task'], - capabilities: {}, + capabilities: {} }); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-x' })); @@ -548,7 +536,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockRunManager.markFailed).toHaveBeenCalledWith( 'run-x', - expect.objectContaining({ errorCode: ErrorCode.MODE_NOT_SUPPORTED }), + expect.objectContaining({ errorCode: ErrorCode.MODE_NOT_SUPPORTED }) ); }); @@ -564,7 +552,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { sessionId: 'sess-ok', state: 'SESSION_STATE_OPEN', mode: 'decision', - initiator: 'agent-1', + initiator: 'agent-1' }); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-ok' })); @@ -574,15 +562,13 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockProvider.getSession).toHaveBeenCalled(); expect(mockRunManager.bindSession).toHaveBeenCalled(); - expect(mockProvider.subscribeSession).toHaveBeenCalledWith( - expect.objectContaining({ runId: 'run-ok' }), - ); + expect(mockProvider.subscribeSession).toHaveBeenCalledWith(expect.objectContaining({ runId: 'run-ok' })); expect(mockStreamConsumer.start).toHaveBeenCalledWith( expect.objectContaining({ runId: 'run-ok', sessionHandle: handle, - subscriberId: 'agent-1', - }), + subscriberId: 'agent-1' + }) ); }); @@ -590,7 +576,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { mockProvider.getSession.mockResolvedValue({ sessionId: 'sess-expired', state: 'SESSION_STATE_EXPIRED', - mode: 'decision', + mode: 'decision' }); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-exp' })); @@ -599,7 +585,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockRunManager.markFailed).toHaveBeenCalledWith( 'run-exp', - expect.objectContaining({ errorCode: ErrorCode.SESSION_EXPIRED }), + expect.objectContaining({ errorCode: ErrorCode.SESSION_EXPIRED }) ); }); @@ -607,7 +593,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { mockProvider.getSession.mockResolvedValue({ sessionId: 'sess-stuck', state: 'SESSION_STATE_UNSPECIFIED', - mode: 'decision', + mode: 'decision' }); mockRunManager.createRun.mockResolvedValue(makeRun({ id: 'run-timeout' })); @@ -616,7 +602,7 @@ describe('RunExecutorService (observer mode, direct-agent-auth)', () => { expect(mockRunManager.markFailed).toHaveBeenCalledWith( 'run-timeout', - expect.objectContaining({ errorCode: ErrorCode.RUNTIME_TIMEOUT }), + expect.objectContaining({ errorCode: ErrorCode.RUNTIME_TIMEOUT }) ); }); }); diff --git a/src/runs/run-executor.service.ts b/src/runs/run-executor.service.ts index 8471295..67129ea 100644 --- a/src/runs/run-executor.service.ts +++ b/src/runs/run-executor.service.ts @@ -98,10 +98,7 @@ export class RunExecutorService { capabilities: initResult.capabilities }; - if ( - initResult.supportedModes.length > 0 && - !initResult.supportedModes.includes(request.session.modeName) - ) { + if (initResult.supportedModes.length > 0 && !initResult.supportedModes.includes(request.session.modeName)) { errors.push( `Runtime does not support mode '${request.session.modeName}'. Supported: ${initResult.supportedModes.join(', ')}` ); @@ -125,16 +122,16 @@ export class RunExecutorService { private resolveSessionId(request: RunDescriptor): string { if (request.session.sessionId) { if (!isValidSessionId(request.session.sessionId)) { - throw new BadRequestException( - 'session.sessionId must be a UUID v4/v7 or base64url 22+ chars', - ); + throw new BadRequestException('session.sessionId must be a UUID v4/v7 or base64url 22+ chars'); } return request.session.sessionId; } return randomUUID(); } - async launch(request: RunDescriptor): Promise<{ run: Awaited>; sessionId: string }> { + async launch( + request: RunDescriptor + ): Promise<{ run: Awaited>; sessionId: string }> { const sessionId = this.resolveSessionId(request); const requestWithSessionId: RunDescriptor = { ...request, @@ -171,11 +168,11 @@ export class RunExecutorService { await provider.cancelSession({ runId, runtimeSessionId: run.runtimeSessionId, - reason, + reason }); } catch (cancelError) { this.logger.warn( - `cancelSession failed for run ${runId} (proceeding with local cancel): ${cancelError instanceof Error ? cancelError.message : String(cancelError)}`, + `cancelSession failed for run ${runId} (proceeding with local cancel): ${cancelError instanceof Error ? cancelError.message : String(cancelError)}` ); } } else if (cancelCallback?.url) { @@ -184,7 +181,7 @@ export class RunExecutorService { } else { // No callback registered and no policy delegation — fail closed. throw new BadRequestException( - 'run has no cancelCallback in metadata and no policy delegation — cannot cancel from control-plane', + 'run has no cancelCallback in metadata and no policy delegation — cannot cancel from control-plane' ); } @@ -197,7 +194,7 @@ export class RunExecutorService { private async invokeCancelCallback( runId: string, callback: { url?: string; bearer?: string }, - reason?: string, + reason?: string ): Promise { if (!callback.url) return; const controller = new AbortController(); @@ -210,28 +207,27 @@ export class RunExecutorService { method: 'POST', headers, body, - signal: controller.signal, + signal: controller.signal }); if (!res.ok) { - throw new AppException( - ErrorCode.INTERNAL_ERROR, - `cancel callback ${callback.url} returned ${res.status}`, - 502, - ); + throw new AppException(ErrorCode.INTERNAL_ERROR, `cancel callback ${callback.url} returned ${res.status}`, 502); } } catch (error) { if (error instanceof AppException) throw error; throw new AppException( ErrorCode.INTERNAL_ERROR, `cancel callback ${callback.url} failed: ${error instanceof Error ? error.message : String(error)}`, - 502, + 502 ); } finally { clearTimeout(timer); } } - async clone(runId: string, overrides?: { tags?: string[] }): Promise<{ run: Awaited>; sessionId: string }> { + async clone( + runId: string, + overrides?: { tags?: string[] } + ): Promise<{ run: Awaited>; sessionId: string }> { const run = await this.runManager.getRun(runId); const executionRequest = run.metadata?.executionRequest as RunDescriptor | undefined; if (!executionRequest) { @@ -280,10 +276,7 @@ export class RunExecutorService { this.logger.log(`runtime instructions: ${initResult.instructions}`); } - if ( - initResult.supportedModes.length > 0 && - !initResult.supportedModes.includes(request.session.modeName) - ) { + if (initResult.supportedModes.length > 0 && !initResult.supportedModes.includes(request.session.modeName)) { throw new AppException( ErrorCode.MODE_NOT_SUPPORTED, `Runtime does not support mode '${request.session.modeName}'. Supported: ${initResult.supportedModes.join(', ')}`, @@ -304,9 +297,9 @@ export class RunExecutorService { { runtimeSessionId: sessionId, initiator: snapshot.initiator ?? '', - ack: { sessionState: snapshot.state }, + ack: { sessionState: snapshot.state } }, - initResult.capabilities as unknown as Record, + initResult.capabilities as unknown as Record ); // Subscribe read-only — never writes. @@ -321,7 +314,7 @@ export class RunExecutorService { runtimeKind: request.runtime.kind, runtimeSessionId: sessionId, subscriberId, - sessionHandle: handle, + sessionHandle: handle }); if (run.traceId) { @@ -356,7 +349,7 @@ export class RunExecutorService { private async pollForOpenSession( provider: ReturnType, runId: string, - sessionId: string, + sessionId: string ) { const startedAt = Date.now(); const base = this.config.sessionPollBaseMs; @@ -372,14 +365,14 @@ export class RunExecutorService { throw new AppException( ErrorCode.SESSION_EXPIRED, `session ${sessionId} expired before any agent opened it`, - 400, + 400 ); } } catch (pollError) { if (pollError instanceof AppException) throw pollError; // getSession failing with NotFound is normal while the agent hasn't called SessionStart yet. this.logger.debug( - `getSession(${sessionId}) attempt ${attempt + 1}: ${pollError instanceof Error ? pollError.message : String(pollError)}`, + `getSession(${sessionId}) attempt ${attempt + 1}: ${pollError instanceof Error ? pollError.message : String(pollError)}` ); } attempt += 1; @@ -390,7 +383,7 @@ export class RunExecutorService { throw new AppException( ErrorCode.RUNTIME_TIMEOUT, `timed out after ${totalTimeout}ms waiting for initiator agent to open session ${sessionId}`, - 504, + 504 ); } @@ -401,28 +394,28 @@ export class RunExecutorService { if (msg.includes('UNKNOWN_POLICY_VERSION')) { await this.runManager.markFailed( runId, - new AppException(ErrorCode.UNKNOWN_POLICY_VERSION, `Unknown policy version: ${msg}`, 400), + new AppException(ErrorCode.UNKNOWN_POLICY_VERSION, `Unknown policy version: ${msg}`, 400) ); return; } if (msg.includes('POLICY_DENIED')) { await this.runManager.markFailed( runId, - new AppException(ErrorCode.POLICY_DENIED, `Policy denied: ${msg}`, 403), + new AppException(ErrorCode.POLICY_DENIED, `Policy denied: ${msg}`, 403) ); return; } if (msg.includes('INVALID_POLICY_DEFINITION')) { await this.runManager.markFailed( runId, - new AppException(ErrorCode.INVALID_POLICY_DEFINITION, `Invalid policy definition: ${msg}`, 400), + new AppException(ErrorCode.INVALID_POLICY_DEFINITION, `Invalid policy definition: ${msg}`, 400) ); return; } if (msg.includes('SESSION_ALREADY_EXISTS') || msg.includes('SessionAlreadyExists')) { await this.runManager.markFailed( runId, - new AppException(ErrorCode.SESSION_ALREADY_EXISTS, `Session already exists: ${msg}`, 409), + new AppException(ErrorCode.SESSION_ALREADY_EXISTS, `Session already exists: ${msg}`, 409) ); return; } @@ -430,7 +423,7 @@ export class RunExecutorService { await this.runManager.markFailed(runId, error); } catch (markFailedError) { this.logger.error( - `failed to mark run ${runId} as failed (run may have been deleted): ${markFailedError instanceof Error ? markFailedError.message : String(markFailedError)}`, + `failed to mark run ${runId} as failed (run may have been deleted): ${markFailedError instanceof Error ? markFailedError.message : String(markFailedError)}` ); } } diff --git a/src/runs/run-manager.service.spec.ts b/src/runs/run-manager.service.spec.ts index 7ff8ef5..9886132 100644 --- a/src/runs/run-manager.service.spec.ts +++ b/src/runs/run-manager.service.spec.ts @@ -24,9 +24,9 @@ function makeExecutionRequest(overrides?: Partial): RunDescriptor modeVersion: '1.0.0', configurationVersion: '1.0.0', ttlMs: 30000, - participants: [{ id: 'agent-a' }, { id: 'agent-b' }], + participants: [{ id: 'agent-a' }, { id: 'agent-b' }] }, - ...overrides, + ...overrides }; } @@ -39,7 +39,7 @@ function makeRunRecord(overrides?: Record) { createdAt: '2026-01-01T00:00:00.000Z', tags: [], metadata: {}, - ...overrides, + ...overrides }; } @@ -55,7 +55,10 @@ function makeEmptyProjection(runId: string): RunStateProjection { trace: { spanCount: 0, linkedArtifacts: [] }, outboundMessages: { total: 0, queued: 0, accepted: 0, rejected: 0 }, policy: { policyVersion: '', commitmentEvaluations: [] }, - llm: { calls: [], totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } }, + llm: { + calls: [], + totals: { callCount: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, estimatedCostUsd: 0 } + } }; } @@ -82,67 +85,67 @@ describe('RunManagerService', () => { markRunning: jest.fn(), markCompleted: jest.fn(), markCancelled: jest.fn(), - markFailed: jest.fn(), - }, + markFailed: jest.fn() + } }, { provide: RuntimeSessionRepository, useValue: { - upsert: jest.fn(), - }, + upsert: jest.fn() + } }, { provide: ProjectionService, useValue: { get: jest.fn(), - empty: jest.fn(), - }, + empty: jest.fn() + } }, { provide: RunEventService, useValue: { - emitControlPlaneEvents: jest.fn().mockResolvedValue([]), - }, + emitControlPlaneEvents: jest.fn().mockResolvedValue([]) + } }, { provide: TraceService, useValue: { startRunTrace: jest.fn().mockReturnValue('trace-abc'), - endRunTrace: jest.fn(), - }, + endRunTrace: jest.fn() + } }, { provide: AuditService, useValue: { - record: jest.fn().mockResolvedValue(undefined), - }, + record: jest.fn().mockResolvedValue(undefined) + } }, { provide: WebhookService, useValue: { - fireEvent: jest.fn(), - }, + fireEvent: jest.fn() + } }, { provide: MetricsService, useValue: { - get: jest.fn().mockResolvedValue(null), - }, + get: jest.fn().mockResolvedValue(null) + } }, { provide: EventRepository, useValue: { - listCanonicalByRun: jest.fn().mockResolvedValue([]), - }, + listCanonicalByRun: jest.fn().mockResolvedValue([]) + } }, { provide: InstrumentationService, useValue: { runStateTotal: { inc: jest.fn() }, - runDuration: { observe: jest.fn() }, - }, - }, - ], + runDuration: { observe: jest.fn() } + } + } + ] }).compile(); service = module.get(RunManagerService); @@ -159,7 +162,7 @@ describe('RunManagerService', () => { runRepository.findByIdempotencyKey.mockResolvedValue(existing as any); const request = makeExecutionRequest({ - execution: { idempotencyKey: 'key-123' }, + execution: { idempotencyKey: 'key-123' } }); const result = await service.createRun(request, FIXED_SESSION_ID); @@ -183,9 +186,7 @@ describe('RunManagerService', () => { expect(traceService.startRunTrace).toHaveBeenCalled(); expect(runEventService.emitControlPlaneEvents).toHaveBeenCalledWith( created.id, - expect.arrayContaining([ - expect.objectContaining({ type: 'run.created' }), - ]), + expect.arrayContaining([expect.objectContaining({ type: 'run.created' })]) ); }); @@ -195,7 +196,7 @@ describe('RunManagerService', () => { runRepository.create.mockResolvedValue(created as any); const request = makeExecutionRequest({ - execution: { idempotencyKey: 'new-key' }, + execution: { idempotencyKey: 'new-key' } }); const result = await service.createRun(request, FIXED_SESSION_ID); @@ -210,7 +211,7 @@ describe('RunManagerService', () => { const run = makeRunRecord({ status: 'starting', startedAt: '2026-01-01T00:01:00.000Z', - traceId: 'trace-abc', + traceId: 'trace-abc' }); runRepository.markStarted.mockResolvedValue(run as any); @@ -224,9 +225,9 @@ describe('RunManagerService', () => { expect.arrayContaining([ expect.objectContaining({ type: 'run.started', - data: expect.objectContaining({ status: 'starting' }), - }), - ]), + data: expect.objectContaining({ status: 'starting' }) + }) + ]) ); }); }); @@ -235,7 +236,7 @@ describe('RunManagerService', () => { it('should return existing run without emitting events if already completed', async () => { const completedRun = makeRunRecord({ status: 'completed', - endedAt: '2026-01-01T00:05:00.000Z', + endedAt: '2026-01-01T00:05:00.000Z' }); runRepository.findById.mockResolvedValue(completedRun as any); @@ -254,7 +255,7 @@ describe('RunManagerService', () => { status: 'completed', endedAt: '2026-01-01T00:05:00.000Z', traceId: 'trace-abc', - runtimeSessionId: 'sess-1', + runtimeSessionId: 'sess-1' }); runRepository.markCompleted.mockResolvedValue(completedRun as any); @@ -267,9 +268,9 @@ describe('RunManagerService', () => { expect.arrayContaining([ expect.objectContaining({ type: 'run.completed', - data: expect.objectContaining({ status: 'completed' }), - }), - ]), + data: expect.objectContaining({ status: 'completed' }) + }) + ]) ); }); }); @@ -293,7 +294,7 @@ describe('RunManagerService', () => { const failedRun = makeRunRecord({ status: 'failed', endedAt: '2026-01-01T00:05:00.000Z', - traceId: 'trace-abc', + traceId: 'trace-abc' }); runRepository.markFailed.mockResolvedValue(failedRun as any); @@ -308,10 +309,10 @@ describe('RunManagerService', () => { type: 'run.failed', data: expect.objectContaining({ status: 'failed', - error: 'something broke', - }), - }), - ]), + error: 'something broke' + }) + }) + ]) ); }); }); @@ -446,7 +447,11 @@ describe('RunManagerService', () => { it('also enriches on markFailed', async () => { const runningRun = makeRunRecord({ status: 'running' }); runRepository.findById.mockResolvedValue(runningRun as any); - const failedRun = makeRunRecord({ status: 'failed', startedAt: '2026-01-01T00:00:00.000Z', endedAt: '2026-01-01T00:00:30.000Z' }); + const failedRun = makeRunRecord({ + status: 'failed', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: '2026-01-01T00:00:30.000Z' + }); runRepository.markFailed.mockResolvedValue(failedRun as any); metricsService.get.mockResolvedValue({ eventCount: 5 }); diff --git a/src/runs/run-manager.service.ts b/src/runs/run-manager.service.ts index 75dafde..9ad830a 100644 --- a/src/runs/run-manager.service.ts +++ b/src/runs/run-manager.service.ts @@ -29,18 +29,18 @@ export class RunManagerService { private readonly instrumentation: InstrumentationService ) {} - async createRun(request: RunDescriptor, sessionId: string) { + async createRun(request: RunDescriptor, sessionId: string, explicitRunId?: string) { const idempotencyKey = request.execution?.idempotencyKey; if (idempotencyKey) { const existing = await this.runRepository.findByIdempotencyKey(idempotencyKey); if (existing) return existing; } - const runId = randomUUID(); + const runId = explicitRunId ?? randomUUID(); const traceId = this.traceService.startRunTrace(runId, { runtime_kind: request.runtime.kind, mode_name: request.session.modeName, - execution_mode: request.mode, + execution_mode: request.mode }); // Auto-tag sandbox runs @@ -71,9 +71,9 @@ export class RunManagerService { : {}), ...(request.session.metadata?.cancellationDelegated ? { cancellationDelegated: request.session.metadata.cancellationDelegated } - : {}), + : {}) }, - traceId, + traceId }); await this.runEventService.emitControlPlaneEvents(record.id, [ @@ -89,9 +89,9 @@ export class RunManagerService { runtimeKind: request.runtime.kind, runtimeVersion: request.runtime.version, sessionId, - traceId, - }, - }, + traceId + } + } ]); return record; @@ -112,9 +112,9 @@ export class RunManagerService { startedAt: run.startedAt, modeName: request.session.modeName, runtimeKind: request.runtime.kind, - traceId: run.traceId, - }, - }, + traceId: run.traceId + } + } ]); return run; } @@ -127,7 +127,7 @@ export class RunManagerService { runId: string, request: RunDescriptor, session: { runtimeSessionId: string; initiator: string; ack: { sessionState: string } }, - capabilities?: Record, + capabilities?: Record ) { const run = await this.runRepository.markBindingSession(runId, session.runtimeSessionId); await this.runtimeSessionRepository.upsert({ @@ -143,8 +143,8 @@ export class RunManagerService { lastSeenAt: new Date().toISOString(), capabilities: (capabilities ?? {}) as Record, metadata: { - participants: request.session.participants, - }, + participants: request.session.participants + } }); const participantEvents = request.session.participants.map((participant) => ({ @@ -154,8 +154,8 @@ export class RunManagerService { subject: { kind: 'participant' as const, id: participant.id }, data: { participantId: participant.id, - status: 'idle', - }, + status: 'idle' + } })); await this.runEventService.emitControlPlaneEvents(runId, [ @@ -172,10 +172,10 @@ export class RunManagerService { modeVersion: request.session.modeVersion, configurationVersion: request.session.configurationVersion, policyVersion: request.session.policyVersion || 'policy.default', - participants: request.session.participants.map((item) => item.id), - }, + participants: request.session.participants.map((item) => item.id) + } }, - ...participantEvents, + ...participantEvents ]); return run; @@ -193,15 +193,15 @@ export class RunManagerService { trace: run.traceId ? { traceId: run.traceId } : undefined, data: { sessionId: runtimeSessionId, - state: 'SESSION_STATE_OPEN', - }, - }, + state: 'SESSION_STATE_OPEN' + } + } ]); void this.webhookService.fireEvent({ event: 'run.started', runId, status: 'running', - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); return run; } @@ -224,15 +224,15 @@ export class RunManagerService { status: 'completed', endedAt: run.endedAt, runtimeSessionId: run.runtimeSessionId, - traceId: run.traceId, - }, - }, + traceId: run.traceId + } + } ]); void this.webhookService.fireEvent({ event: 'run.completed', runId, status: 'completed', - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); void this.enrichRunMetadata(runId, run); return run; @@ -256,15 +256,15 @@ export class RunManagerService { status: 'cancelled', endedAt: run.endedAt, runtimeSessionId: run.runtimeSessionId, - traceId: run.traceId, - }, - }, + traceId: run.traceId + } + } ]); void this.webhookService.fireEvent({ event: 'run.cancelled', runId, status: 'cancelled', - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() }); return run; } @@ -289,16 +289,16 @@ export class RunManagerService { endedAt: run.endedAt, runtimeSessionId: run.runtimeSessionId, traceId: run.traceId, - error: message, - }, - }, + error: message + } + } ]); void this.webhookService.fireEvent({ event: 'run.failed', runId, status: 'failed', timestamp: new Date().toISOString(), - data: { error: message }, + data: { error: message } }); void this.enrichRunMetadata(runId, run); return run; @@ -331,8 +331,8 @@ export class RunManagerService { includeArchived: filters.includeArchived, environment: filters.environment, scenarioRef: filters.scenarioRef, - search: filters.search, - }), + search: filters.search + }) ]); return { data, total, limit, offset }; } @@ -348,7 +348,7 @@ export class RunManagerService { actorType: 'system', action: 'run.deleted', resource: 'run', - resourceId: runId, + resourceId: runId }); await this.runRepository.delete(runId); } @@ -360,7 +360,7 @@ export class RunManagerService { actorType: 'system', action: 'run.archived', resource: 'run', - resourceId: runId, + resourceId: runId }); return this.runRepository.archive(runId); } @@ -371,6 +371,10 @@ export class RunManagerService { return run; } + async findBySessionId(sessionId: string) { + return this.runRepository.findByRuntimeSessionId(sessionId); + } + async getState(runId: string): Promise { await this.getRun(runId); return (await this.projectionService.get(runId)) ?? this.projectionService.empty(runId); @@ -378,12 +382,17 @@ export class RunManagerService { private async enrichRunMetadata( runId: string, - run: { startedAt?: string | null; endedAt?: string | null; metadata?: Record | null; status?: string }, + run: { + startedAt?: string | null; + endedAt?: string | null; + metadata?: Record | null; + status?: string; + } ) { try { const [metrics, events] = await Promise.all([ this.metricsService.get(runId), - this.eventRepository.listCanonicalByRun(runId, 0, 2000), + this.eventRepository.listCanonicalByRun(runId, 0, 2000) ]); const decisionEvent = [...events].reverse().find((e) => e.type === 'decision.finalized'); @@ -393,15 +402,15 @@ export class RunManagerService { const durationMs = run.startedAt && run.endedAt ? new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime() - : metrics?.durationMs ?? undefined; + : (metrics?.durationMs ?? undefined); if (durationMs !== undefined && durationMs >= 0) { const modeName = - (run.metadata?.executionRequest as { session?: { modeName?: string } } | undefined)?.session?.modeName - ?? 'unknown'; + (run.metadata?.executionRequest as { session?: { modeName?: string } } | undefined)?.session?.modeName ?? + 'unknown'; this.instrumentation.runDuration.observe( { terminal_status: run.status ?? 'unknown', mode_name: modeName }, - durationMs / 1000, + durationMs / 1000 ); } @@ -415,11 +424,13 @@ export class RunManagerService { if (Object.keys(enrichment).length > 0) { await this.runRepository.update(runId, { - metadata: { ...(run.metadata ?? {}), ...enrichment }, + metadata: { ...(run.metadata ?? {}), ...enrichment } }); } } catch (err) { - this.logger.warn(`Failed to enrich metadata for run ${runId}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Failed to enrich metadata for run ${runId}: ${err instanceof Error ? err.message : String(err)}` + ); } } } diff --git a/src/runs/run-recovery.service.spec.ts b/src/runs/run-recovery.service.spec.ts index 37e036e..44f5442 100644 --- a/src/runs/run-recovery.service.spec.ts +++ b/src/runs/run-recovery.service.spec.ts @@ -156,10 +156,7 @@ describe('RunRecoveryService', () => { await service.onApplicationBootstrap(); - expect(mockRunManager.markFailed).toHaveBeenCalledWith( - 'run-3', - expect.any(Error) - ); + expect(mockRunManager.markFailed).toHaveBeenCalledWith('run-3', expect.any(Error)); }); it('does not crash if markFailed also fails', async () => { diff --git a/src/runs/session-discovery.service.ts b/src/runs/session-discovery.service.ts new file mode 100644 index 0000000..d48f001 --- /dev/null +++ b/src/runs/session-discovery.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { RunDescriptor } from '../contracts/control-plane'; +import { SessionLifecycleEvent, RuntimeSessionSnapshot } from '../contracts/runtime'; +import { RuntimeProviderRegistry } from '../runtime/runtime-provider.registry'; +import { RunManagerService } from './run-manager.service'; +import { StreamConsumerService } from './stream-consumer.service'; +import { InstrumentationService } from '../telemetry/instrumentation.service'; +import { AppConfigService } from '../config/app-config.service'; + +/** + * Subscribes to the runtime's WatchSessions stream and auto-creates run + * records for discovered sessions. This enables the CP to observe sessions + * that were started by external launchers (not via POST /runs). + */ +@Injectable() +export class SessionDiscoveryService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SessionDiscoveryService.name); + private aborted = false; + private readonly knownSessions = new Set(); + + constructor( + private readonly providerRegistry: RuntimeProviderRegistry, + private readonly runManager: RunManagerService, + private readonly streamConsumer: StreamConsumerService, + private readonly instrumentation: InstrumentationService, + private readonly config: AppConfigService + ) {} + + async onModuleInit(): Promise { + if (!this.config.sessionDiscoveryEnabled) { + this.logger.log('Session discovery disabled (SESSION_DISCOVERY_ENABLED=false)'); + return; + } + void this.startDiscoveryLoop(); + } + + onModuleDestroy(): void { + this.aborted = true; + } + + private async startDiscoveryLoop(): Promise { + this.logger.log('Starting session discovery via WatchSessions'); + + while (!this.aborted) { + try { + await this.consumeWatchStream(); + } catch (error) { + if (this.aborted) return; + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`WatchSessions stream ended: ${message}. Reconnecting in 5s...`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + } + + private async consumeWatchStream(): Promise { + const provider = this.providerRegistry.get('rust'); + const stream = provider.watchSessions(); + + for await (const event of stream) { + if (this.aborted) return; + + const sessionId = event.session?.sessionId; + if (!sessionId) continue; + + if (event.eventType === 'created') { + await this.handleSessionCreated(event, provider); + } else if (event.eventType === 'resolved') { + await this.handleSessionTerminal(sessionId, 'completed'); + } else if (event.eventType === 'expired') { + await this.handleSessionTerminal(sessionId, 'failed'); + } + } + } + + private async handleSessionCreated( + event: SessionLifecycleEvent, + provider: ReturnType + ): Promise { + const session = event.session; + if (this.knownSessions.has(session.sessionId)) return; + this.knownSessions.add(session.sessionId); + + const existing = await this.runManager.findBySessionId(session.sessionId); + if (existing) { + this.logger.debug(`Session ${session.sessionId} already has run ${existing.id}`); + return; + } + + const descriptor = this.buildRunDescriptor(session); + const run = await this.runManager.createRun(descriptor, session.sessionId, session.sessionId); + + this.logger.log( + `Auto-discovered session ${session.sessionId} → run ${run.id} (mode=${session.mode}, initiator=${session.initiator})` + ); + + void this.runManager.markStarted(run.id, descriptor); + void this.runManager.bindSession(run.id, descriptor, { + runtimeSessionId: session.sessionId, + initiator: session.initiator ?? '', + ack: { sessionState: session.state } + }); + void this.runManager.markRunning(run.id, session.sessionId); + + const handle = provider.subscribeSession({ + runId: run.id, + runtimeSessionId: session.sessionId + }); + + void this.streamConsumer.start({ + runId: run.id, + execution: descriptor, + runtimeKind: 'rust', + runtimeSessionId: session.sessionId, + subscriberId: `discovery-${run.id}`, + sessionHandle: handle + }); + } + + private async handleSessionTerminal(sessionId: string, status: 'completed' | 'failed'): Promise { + const run = await this.runManager.findBySessionId(sessionId); + if (!run) return; + + if (['completed', 'failed', 'cancelled'].includes(run.status)) return; + + if (status === 'completed') { + await this.runManager.markCompleted(run.id); + } else { + await this.runManager.markFailed(run.id, new Error('session expired')); + } + this.logger.log(`Session ${sessionId} → run ${run.id} marked ${status}`); + } + + private buildRunDescriptor(session: RuntimeSessionSnapshot): RunDescriptor { + return { + mode: 'live', + runtime: { kind: 'rust' }, + session: { + sessionId: session.sessionId, + modeName: session.mode, + modeVersion: session.modeVersion ?? '1.0.0', + configurationVersion: session.configurationVersion ?? 'config.default', + policyVersion: session.policyVersion, + ttlMs: + session.expiresAtUnixMs && session.startedAtUnixMs + ? session.expiresAtUnixMs - session.startedAtUnixMs + : 300000, + participants: [], + metadata: { + source: 'session-discovery', + discoveredAt: new Date().toISOString(), + initiator: session.initiator + } + } + }; + } +} diff --git a/src/runs/stream-consumer.service.spec.ts b/src/runs/stream-consumer.service.spec.ts index fdca8a5..4577232 100644 --- a/src/runs/stream-consumer.service.spec.ts +++ b/src/runs/stream-consumer.service.spec.ts @@ -20,38 +20,38 @@ describe('StreamConsumerService', () => { beforeEach(() => { runtimeRegistry = { - get: jest.fn(), + get: jest.fn() } as unknown as jest.Mocked; normalizer = { - normalize: jest.fn().mockReturnValue([]), + normalize: jest.fn().mockReturnValue([]) } as unknown as jest.Mocked; eventService = { emitControlPlaneEvents: jest.fn().mockResolvedValue([]), - persistRawAndCanonical: jest.fn().mockResolvedValue([]), + persistRawAndCanonical: jest.fn().mockResolvedValue([]) } as unknown as jest.Mocked; runtimeSessionRepository = { - updateState: jest.fn().mockResolvedValue(null), + updateState: jest.fn().mockResolvedValue(null) } as unknown as jest.Mocked; runManager = { markCompleted: jest.fn().mockResolvedValue({}), - markFailed: jest.fn().mockResolvedValue({}), + markFailed: jest.fn().mockResolvedValue({}) } as unknown as jest.Mocked; streamHub = { complete: jest.fn(), publishEvent: jest.fn(), - publishSnapshot: jest.fn(), + publishSnapshot: jest.fn() } as unknown as jest.Mocked; config = { streamBackoffBaseMs: 250, streamBackoffMaxMs: 30000, streamIdleTimeoutMs: 120000, - streamMaxRetries: 5, + streamMaxRetries: 5 } as AppConfigService; service = new StreamConsumerService( @@ -62,13 +62,16 @@ describe('StreamConsumerService', () => { runManager, streamHub, config, - { activeStreams: { inc: jest.fn(), dec: jest.fn() }, streamReconnectsTotal: { inc: jest.fn() } } as unknown as InstrumentationService, + { + activeStreams: { inc: jest.fn(), dec: jest.fn() }, + streamReconnectsTotal: { inc: jest.fn() } + } as unknown as InstrumentationService, { withRunSpan: jest.fn((_runId: string, _name: string, _attrs: unknown, fn: () => Promise) => fn()), withSpan: jest.fn((_name: string, _attrs: unknown, fn: () => Promise) => fn()), addRunSpanEvent: jest.fn(), - getRunTraceContext: jest.fn().mockReturnValue(undefined), - } as any, + getRunTraceContext: jest.fn().mockReturnValue(undefined) + } as any ); }); @@ -82,12 +85,12 @@ describe('StreamConsumerService', () => { Math.random = () => 0; try { - expect(backoffMs(0)).toBe(250); // 250 * 2^0 = 250 - expect(backoffMs(1)).toBe(500); // 250 * 2^1 = 500 - expect(backoffMs(2)).toBe(1000); // 250 * 2^2 = 1000 - expect(backoffMs(3)).toBe(2000); // 250 * 2^3 = 2000 - expect(backoffMs(4)).toBe(4000); // 250 * 2^4 = 4000 - expect(backoffMs(5)).toBe(8000); // 250 * 2^5 = 8000 + expect(backoffMs(0)).toBe(250); // 250 * 2^0 = 250 + expect(backoffMs(1)).toBe(500); // 250 * 2^1 = 500 + expect(backoffMs(2)).toBe(1000); // 250 * 2^2 = 1000 + expect(backoffMs(3)).toBe(2000); // 250 * 2^3 = 2000 + expect(backoffMs(4)).toBe(4000); // 250 * 2^4 = 4000 + expect(backoffMs(5)).toBe(8000); // 250 * 2^5 = 8000 } finally { Math.random = originalRandom; } @@ -128,7 +131,7 @@ describe('StreamConsumerService', () => { describe('start()', () => { it('should be idempotent — second call returns immediately without starting a new loop', async () => { const mockProvider = { - getSession: jest.fn().mockReturnValue(new Promise(() => {})), // never resolves, keeps loop active + getSession: jest.fn().mockReturnValue(new Promise(() => {})) // never resolves, keeps loop active }; runtimeRegistry.get.mockReturnValue(mockProvider as any); @@ -142,12 +145,12 @@ describe('StreamConsumerService', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }, runtimeKind: 'rust', runtimeSessionId: 'session-1', - subscriberId: 'sub-1', + subscriberId: 'sub-1' }; await service.start(params); @@ -162,7 +165,7 @@ describe('StreamConsumerService', () => { describe('stop()', () => { it('should set the aborted flag on the active stream marker', async () => { const mockProvider = { - getSession: jest.fn().mockReturnValue(new Promise(() => {})), + getSession: jest.fn().mockReturnValue(new Promise(() => {})) }; runtimeRegistry.get.mockReturnValue(mockProvider as any); @@ -176,12 +179,12 @@ describe('StreamConsumerService', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }, runtimeKind: 'rust', runtimeSessionId: 'session-1', - subscriberId: 'sub-1', + subscriberId: 'sub-1' }; await service.start(params); @@ -204,7 +207,7 @@ describe('StreamConsumerService', () => { describe('onModuleDestroy()', () => { it('should abort all active streams', async () => { const mockProvider = { - getSession: jest.fn().mockReturnValue(new Promise(() => {})), + getSession: jest.fn().mockReturnValue(new Promise(() => {})) }; runtimeRegistry.get.mockReturnValue(mockProvider as any); @@ -218,12 +221,12 @@ describe('StreamConsumerService', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }, runtimeKind: 'rust', runtimeSessionId: `session-${runId}`, - subscriberId: 'sub-1', + subscriberId: 'sub-1' }); await service.start(makeParams('run-a')); @@ -250,7 +253,7 @@ describe('StreamConsumerService', () => { it('should return false when a stream is active but not connected', async () => { const mockProvider = { - getSession: jest.fn().mockReturnValue(new Promise(() => {})), + getSession: jest.fn().mockReturnValue(new Promise(() => {})) }; runtimeRegistry.get.mockReturnValue(mockProvider as any); @@ -264,12 +267,12 @@ describe('StreamConsumerService', () => { modeVersion: '1.0', configurationVersion: '1.0', ttlMs: 60000, - participants: [{ id: 'agent-1' }], - }, + participants: [{ id: 'agent-1' }] + } }, runtimeKind: 'rust', runtimeSessionId: 'session-1', - subscriberId: 'sub-1', + subscriberId: 'sub-1' }); // Stream is active but not yet connected diff --git a/src/runs/stream-consumer.service.ts b/src/runs/stream-consumer.service.ts index dbbf01e..92899f8 100644 --- a/src/runs/stream-consumer.service.ts +++ b/src/runs/stream-consumer.service.ts @@ -128,9 +128,7 @@ export class StreamConsumerService implements OnModuleDestroy { ): Promise { const provider = this.runtimeRegistry.get(params.runtimeKind); const context = { - knownParticipants: new Set( - params.execution.session.participants.map((item) => item.id), - ), + knownParticipants: new Set(params.execution.session.participants.map((item) => item.id)), execution: params.execution, runtimeSessionId: params.runtimeSessionId }; @@ -147,7 +145,9 @@ export class StreamConsumerService implements OnModuleDestroy { } } catch (error) { marker.connected = false; - this.logger.warn(`stream error for run ${params.runId}: ${error instanceof Error ? error.message : String(error)}`); + this.logger.warn( + `stream error for run ${params.runId}: ${error instanceof Error ? error.message : String(error)}` + ); } // Stream ended — check if already finalized @@ -189,7 +189,12 @@ export class StreamConsumerService implements OnModuleDestroy { retries += 1; this.instrumentation.streamReconnectsTotal.inc(); if (retries > maxRetries) { - await this.finalizeRun(params.runId, marker, 'failed', new Error('polling exhausted without terminal session state')); + await this.finalizeRun( + params.runId, + marker, + 'failed', + new Error('polling exhausted without terminal session state') + ); return; } @@ -206,10 +211,7 @@ export class StreamConsumerService implements OnModuleDestroy { } } - private async *withIdleTimeout( - iterable: AsyncIterable, - timeoutMs: number - ): AsyncIterable { + private async *withIdleTimeout(iterable: AsyncIterable, timeoutMs: number): AsyncIterable { const iterator = iterable[Symbol.asyncIterator](); try { while (true) { @@ -279,11 +281,7 @@ export class StreamConsumerService implements OnModuleDestroy { const sessionStateChange = emitted.find((event) => event.type === 'session.state.changed'); if (sessionStateChange && typeof sessionStateChange.data.state === 'string') { - await this.runtimeSessionRepository.updateState( - runId, - sessionStateChange.data.state, - new Date().toISOString() - ); + await this.runtimeSessionRepository.updateState(runId, sessionStateChange.data.state, new Date().toISOString()); if (sessionStateChange.data.state === 'SESSION_STATE_RESOLVED') { await this.finalizeRun(runId, marker, 'completed'); return; @@ -293,6 +291,5 @@ export class StreamConsumerService implements OnModuleDestroy { return; } } - } } diff --git a/src/runtime/circuit-breaker.spec.ts b/src/runtime/circuit-breaker.spec.ts index eefbcf6..a144cef 100644 --- a/src/runtime/circuit-breaker.spec.ts +++ b/src/runtime/circuit-breaker.spec.ts @@ -3,7 +3,7 @@ import { CircuitBreaker, CircuitBreakerConfig } from './circuit-breaker'; describe('CircuitBreaker', () => { const defaultConfig: CircuitBreakerConfig = { failureThreshold: 3, - resetTimeoutMs: 5000, + resetTimeoutMs: 5000 }; let breaker: CircuitBreaker; @@ -29,18 +29,14 @@ describe('CircuitBreaker', () => { it('stays CLOSED when failures are below threshold', async () => { for (let i = 0; i < defaultConfig.failureThreshold - 1; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } expect(breaker.getState()).toBe('CLOSED'); }); it('opens after reaching failure threshold', async () => { for (let i = 0; i < defaultConfig.failureThreshold; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } expect(breaker.getState()).toBe('OPEN'); }); @@ -48,17 +44,13 @@ describe('CircuitBreaker', () => { it('rejects calls when OPEN', async () => { // Trip the breaker for (let i = 0; i < defaultConfig.failureThreshold; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } expect(breaker.getState()).toBe('OPEN'); // Subsequent call should be rejected without executing the function const fn = jest.fn().mockResolvedValue('should not run'); - await expect(breaker.execute(fn)).rejects.toThrow( - 'Circuit breaker is OPEN', - ); + await expect(breaker.execute(fn)).rejects.toThrow('Circuit breaker is OPEN'); expect(fn).not.toHaveBeenCalled(); }); @@ -68,9 +60,7 @@ describe('CircuitBreaker', () => { // Trip the breaker for (let i = 0; i < defaultConfig.failureThreshold; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } expect(breaker.getState()).toBe('OPEN'); @@ -85,9 +75,7 @@ describe('CircuitBreaker', () => { // Trip the breaker for (let i = 0; i < defaultConfig.failureThreshold; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } // Advance time past reset timeout to enter HALF_OPEN @@ -106,9 +94,7 @@ describe('CircuitBreaker', () => { // Trip the breaker for (let i = 0; i < defaultConfig.failureThreshold; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } // Advance time past reset timeout to enter HALF_OPEN @@ -117,18 +103,14 @@ describe('CircuitBreaker', () => { // A failure in HALF_OPEN should re-open the circuit // The failure count was already at threshold; one more failure pushes it above - await expect( - breaker.execute(() => Promise.reject(new Error('still failing'))), - ).rejects.toThrow('still failing'); + await expect(breaker.execute(() => Promise.reject(new Error('still failing')))).rejects.toThrow('still failing'); expect(breaker.getState()).toBe('OPEN'); }); it('resets failure count on success', async () => { // Accumulate failures just below threshold for (let i = 0; i < defaultConfig.failureThreshold - 1; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } expect(breaker.getState()).toBe('CLOSED'); @@ -138,26 +120,20 @@ describe('CircuitBreaker', () => { // Now we need the full threshold again to trip the breaker for (let i = 0; i < defaultConfig.failureThreshold - 1; i++) { - await expect( - breaker.execute(() => Promise.reject(new Error('fail'))), - ).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); } // Should still be CLOSED because count was reset expect(breaker.getState()).toBe('CLOSED'); }); it('returns the value from the executed function', async () => { - const result = await breaker.execute(() => - Promise.resolve({ data: 'hello' }), - ); + const result = await breaker.execute(() => Promise.resolve({ data: 'hello' })); expect(result).toEqual({ data: 'hello' }); }); it('propagates the original error from the executed function', async () => { const originalError = new Error('specific error'); - await expect(breaker.execute(() => Promise.reject(originalError))).rejects.toBe( - originalError, - ); + await expect(breaker.execute(() => Promise.reject(originalError))).rejects.toBe(originalError); }); describe('getHistory (§5.3)', () => { diff --git a/src/runtime/circuit-breaker.ts b/src/runtime/circuit-breaker.ts index 210cdb9..b7cf726 100644 --- a/src/runtime/circuit-breaker.ts +++ b/src/runtime/circuit-breaker.ts @@ -17,6 +17,7 @@ export interface CircuitBreakerConfig { resetTimeoutMs: number; onStateChange?: (state: CircuitBreakerState, event: 'success' | 'failure') => void; instrumentation?: InstrumentationService; + isExpectedError?: (error: unknown) => boolean; } export class CircuitBreaker { @@ -73,6 +74,9 @@ export class CircuitBreaker { this.onSuccess(); return result; } catch (error) { + if (this.config.isExpectedError?.(error)) { + throw error; + } this.onFailure(); throw error; } diff --git a/src/runtime/grpc-helpers.spec.ts b/src/runtime/grpc-helpers.spec.ts index 7360c20..56c5fdf 100644 --- a/src/runtime/grpc-helpers.spec.ts +++ b/src/runtime/grpc-helpers.spec.ts @@ -1,11 +1,5 @@ import * as grpc from '@grpc/grpc-js'; -import { - buildMetadata, - fromAck, - fromEnvelope, - fromSessionMetadata, - getClientMethod, -} from './grpc-helpers'; +import { buildMetadata, fromAck, fromEnvelope, fromSessionMetadata, getClientMethod } from './grpc-helpers'; describe('gRPC helpers (Q3-1)', () => { describe('fromEnvelope', () => { @@ -18,7 +12,7 @@ describe('gRPC helpers (Q3-1)', () => { sessionId: 'sess-1', sender: 'agent-a', timestampUnixMs: '1700000000000', // Long serialized as string (longs: String) - payload: Buffer.from('hello'), + payload: Buffer.from('hello') }; const result = fromEnvelope(raw); expect(result.timestampUnixMs).toBe(1700000000000); @@ -28,8 +22,13 @@ describe('gRPC helpers (Q3-1)', () => { it('coerces non-buffer payload to Buffer', () => { const result = fromEnvelope({ - macpVersion: '1.0', mode: '', messageType: '', messageId: '', - sessionId: '', sender: '', payload: 'hello', + macpVersion: '1.0', + mode: '', + messageType: '', + messageId: '', + sessionId: '', + sender: '', + payload: 'hello' }); expect(Buffer.isBuffer(result.payload)).toBe(true); }); @@ -37,8 +36,12 @@ describe('gRPC helpers (Q3-1)', () => { it('defaults timestampUnixMs to now when missing', () => { const before = Date.now(); const result = fromEnvelope({ - macpVersion: '1.0', mode: '', messageType: '', messageId: '', - sessionId: '', sender: '', + macpVersion: '1.0', + mode: '', + messageType: '', + messageId: '', + sessionId: '', + sender: '' }); expect(result.timestampUnixMs).toBeGreaterThanOrEqual(before); }); @@ -56,7 +59,7 @@ describe('gRPC helpers (Q3-1)', () => { const detailsBytes = Buffer.from(JSON.stringify({ reasons: ['rule-x', 'rule-y'] })); const ack = fromAck({ ok: false, - error: { code: 'POLICY_DENIED', message: 'no', details: detailsBytes }, + error: { code: 'POLICY_DENIED', message: 'no', details: detailsBytes } }); expect(ack.error?.reasons).toEqual(['rule-x', 'rule-y']); expect(ack.error?.code).toBe('POLICY_DENIED'); @@ -73,7 +76,7 @@ describe('gRPC helpers (Q3-1)', () => { it('tolerates malformed details JSON by leaving reasons undefined', () => { const ack = fromAck({ ok: false, - error: { code: 'POLICY_DENIED', message: '', details: Buffer.from('{not json') }, + error: { code: 'POLICY_DENIED', message: '', details: Buffer.from('{not json') } }); expect(ack.error?.reasons).toBeUndefined(); }); @@ -86,7 +89,7 @@ describe('gRPC helpers (Q3-1)', () => { mode: 'decision', state: 'SESSION_STATE_OPEN', initiator: 'agent-1', - startedAtUnixMs: '123', + startedAtUnixMs: '123' }); expect(snap.sessionId).toBe('s'); expect(snap.state).toBe('SESSION_STATE_OPEN'); diff --git a/src/runtime/grpc-helpers.ts b/src/runtime/grpc-helpers.ts index 8ab2c94..795585a 100644 --- a/src/runtime/grpc-helpers.ts +++ b/src/runtime/grpc-helpers.ts @@ -21,7 +21,7 @@ export function fromEnvelope(envelope: any): RuntimeEnvelope { sessionId: envelope.sessionId, sender: envelope.sender, timestampUnixMs: Number(envelope.timestampUnixMs ?? Date.now()), - payload: Buffer.isBuffer(envelope.payload) ? envelope.payload : Buffer.from(envelope.payload ?? ''), + payload: Buffer.isBuffer(envelope.payload) ? envelope.payload : Buffer.from(envelope.payload ?? '') }; } @@ -33,7 +33,9 @@ export function fromAck(ack: any, trailingMetadata?: grpc.Metadata): RuntimeAck try { const parsed = JSON.parse(Buffer.from(ack.error.details).toString('utf-8')); if (Array.isArray(parsed.reasons)) reasons = parsed.reasons; - } catch { /* ignore parse errors */ } + } catch { + /* ignore parse errors */ + } } if (!reasons && trailingMetadata) { @@ -42,7 +44,9 @@ export function fromAck(ack: any, trailingMetadata?: grpc.Metadata): RuntimeAck try { const parsed = JSON.parse(Buffer.from(detailsBin[0] as Buffer).toString('utf-8')); if (Array.isArray(parsed.reasons)) reasons = parsed.reasons; - } catch { /* ignore parse errors */ } + } catch { + /* ignore parse errors */ + } } } @@ -61,9 +65,9 @@ export function fromAck(ack: any, trailingMetadata?: grpc.Metadata): RuntimeAck messageId: ack.error.messageId, detailsBase64: ack.error.details ? Buffer.from(ack.error.details).toString('base64') : undefined, details: ack.error.details ? Buffer.from(ack.error.details) : undefined, - reasons, + reasons } - : undefined, + : undefined }; } @@ -78,7 +82,7 @@ export function fromSessionMetadata(metadata: any): RuntimeSessionSnapshot { modeVersion: metadata?.modeVersion, configurationVersion: metadata?.configurationVersion, policyVersion: metadata?.policyVersion, - initiator: metadata?.initiator ?? undefined, + initiator: metadata?.initiator ?? undefined }; } diff --git a/src/runtime/observer-invariant.spec.ts b/src/runtime/observer-invariant.spec.ts index f650647..95810a5 100644 --- a/src/runtime/observer-invariant.spec.ts +++ b/src/runtime/observer-invariant.spec.ts @@ -14,27 +14,27 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ { pattern: /provider\.send\s*\(/, message: - 'provider.send() is forbidden — the control-plane must never emit envelopes. ' - + 'Agents speak for themselves via macp-sdk-* (direct-agent-auth §Invariants #5).', + 'provider.send() is forbidden — the control-plane must never emit envelopes. ' + + 'Agents speak for themselves via macp-sdk-* (direct-agent-auth §Invariants #5).' }, { pattern: /openSession\s*\(/, message: - 'openSession() is forbidden — it forges SessionStart on the agent\'s behalf. ' - + 'Use provider.subscribeSession() for read-only observation (CP-3).', + "openSession() is forbidden — it forges SessionStart on the agent's behalf. " + + 'Use provider.subscribeSession() for read-only observation (CP-3).' }, { pattern: /chooseInitiator\s*\(/, message: - 'chooseInitiator() is forbidden — the control-plane must not pick an initiator. ' - + 'The initiator is whichever agent calls SessionStart; learned via GetSession (CP-3).', + 'chooseInitiator() is forbidden — the control-plane must not pick an initiator. ' + + 'The initiator is whichever agent calls SessionStart; learned via GetSession (CP-3).' }, { pattern: /retryKickoff\s*\(/, message: - 'retryKickoff() is forbidden — kickoff messages are emitted by the initiator agent ' - + 'via its SDK, not by the control-plane (CP-4).', - }, + 'retryKickoff() is forbidden — kickoff messages are emitted by the initiator agent ' + + 'via its SDK, not by the control-plane (CP-4).' + } ]; function walk(dir: string): string[] { @@ -82,9 +82,7 @@ describe('Observer invariant — no envelope-forging paths in src/', () => { } if (violations.length > 0) { - const msg = violations - .map((v) => ` ${v.file}:${v.line}: ${v.text}`) - .join('\n'); + const msg = violations.map((v) => ` ${v.file}:${v.line}: ${v.text}`).join('\n'); throw new Error(`${message}\n\nFound ${violations.length} violation(s):\n${msg}`); } }); diff --git a/src/runtime/proto-registry.service.spec.ts b/src/runtime/proto-registry.service.spec.ts index 19d286e..7bf0b20 100644 --- a/src/runtime/proto-registry.service.spec.ts +++ b/src/runtime/proto-registry.service.spec.ts @@ -55,9 +55,7 @@ describe('ProtoRegistryService', () => { describe('onModuleInit', () => { it('loads proto files via protobufjs.loadSync', () => { expect(mockLoadSync).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.stringContaining('proto/macp/v1/core.proto') - ]) + expect.arrayContaining([expect.stringContaining('proto/macp/v1/core.proto')]) ); }); @@ -73,22 +71,18 @@ describe('ProtoRegistryService', () => { // ========================================================================= describe('getKnownTypeName', () => { it('returns core type for __core__ messages', () => { - expect(service.getKnownTypeName('__core__', 'SessionStart')).toBe( - 'macp.v1.SessionStartPayload' - ); + expect(service.getKnownTypeName('__core__', 'SessionStart')).toBe('macp.v1.SessionStartPayload'); }); it('returns core type when modeName does not match but messageType is core', () => { // Falls back to __core__ lookup - expect(service.getKnownTypeName('unknown.mode', 'Signal')).toBe( - 'macp.v1.SignalPayload' - ); + expect(service.getKnownTypeName('unknown.mode', 'Signal')).toBe('macp.v1.SignalPayload'); }); it('returns mode-specific type for decision mode', () => { - expect( - service.getKnownTypeName('macp.mode.decision.v1', 'Proposal') - ).toBe('macp.modes.decision.v1.ProposalPayload'); + expect(service.getKnownTypeName('macp.mode.decision.v1', 'Proposal')).toBe( + 'macp.modes.decision.v1.ProposalPayload' + ); }); it('returns mode-specific type for task mode', () => { @@ -98,21 +92,19 @@ describe('ProtoRegistryService', () => { }); it('returns mode-specific type for handoff mode', () => { - expect( - service.getKnownTypeName('macp.mode.handoff.v1', 'HandoffOffer') - ).toBe('macp.modes.handoff.v1.HandoffOfferPayload'); + expect(service.getKnownTypeName('macp.mode.handoff.v1', 'HandoffOffer')).toBe( + 'macp.modes.handoff.v1.HandoffOfferPayload' + ); }); it('returns mode-specific type for quorum mode', () => { - expect( - service.getKnownTypeName('macp.mode.quorum.v1', 'ApprovalRequest') - ).toBe('macp.modes.quorum.v1.ApprovalRequestPayload'); + expect(service.getKnownTypeName('macp.mode.quorum.v1', 'ApprovalRequest')).toBe( + 'macp.modes.quorum.v1.ApprovalRequestPayload' + ); }); it('returns undefined for unknown type in unknown mode', () => { - expect( - service.getKnownTypeName('unknown.mode', 'UnknownMessage') - ).toBeUndefined(); + expect(service.getKnownTypeName('unknown.mode', 'UnknownMessage')).toBeUndefined(); }); }); @@ -135,19 +127,13 @@ describe('ProtoRegistryService', () => { service.decodeKnown('macp.mode.decision.v1', 'Proposal', payload); - expect(mockLookupType).toHaveBeenCalledWith( - 'macp.modes.decision.v1.ProposalPayload' - ); + expect(mockLookupType).toHaveBeenCalledWith('macp.modes.decision.v1.ProposalPayload'); }); it('falls back to tryDecodeUtf8 for unknown types with JSON payload', () => { const jsonPayload = Buffer.from(JSON.stringify({ key: 'val' }), 'utf8'); - const result = service.decodeKnown( - 'unknown.mode', - 'CustomMessage', - jsonPayload - ); + const result = service.decodeKnown('unknown.mode', 'CustomMessage', jsonPayload); expect(result).toEqual({ json: { key: 'val' }, @@ -158,11 +144,7 @@ describe('ProtoRegistryService', () => { it('falls back to tryDecodeUtf8 for unknown types with non-JSON payload', () => { const textPayload = Buffer.from('just plain text', 'utf8'); - const result = service.decodeKnown( - 'unknown.mode', - 'CustomMessage', - textPayload - ); + const result = service.decodeKnown('unknown.mode', 'CustomMessage', textPayload); expect(result).toEqual({ text: 'just plain text', @@ -172,14 +154,9 @@ describe('ProtoRegistryService', () => { }); it('returns undefined for unknown types with empty payload', () => { - const result = service.decodeKnown( - 'unknown.mode', - 'CustomMessage', - Buffer.alloc(0) - ); + const result = service.decodeKnown('unknown.mode', 'CustomMessage', Buffer.alloc(0)); expect(result).toBeUndefined(); }); }); - }); diff --git a/src/runtime/proto-registry.service.ts b/src/runtime/proto-registry.service.ts index c31ae3c..35301fe 100644 --- a/src/runtime/proto-registry.service.ts +++ b/src/runtime/proto-registry.service.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import * as protobuf from 'protobufjs'; const MESSAGE_TYPE_MAP: Record> = { - '__core__': { + __core__: { SessionStart: 'macp.v1.SessionStartPayload', Commitment: 'macp.v1.CommitmentPayload', Signal: 'macp.v1.SignalPayload', @@ -94,8 +94,7 @@ export class ProtoRegistryService implements OnModuleInit { } decodeKnown(modeName: string, messageType: string, payload: Buffer): Record | undefined { - const typeName = - MESSAGE_TYPE_MAP[modeName]?.[messageType] ?? MESSAGE_TYPE_MAP.__core__[messageType]; + const typeName = MESSAGE_TYPE_MAP[modeName]?.[messageType] ?? MESSAGE_TYPE_MAP.__core__[messageType]; if (!typeName) { return this.tryDecodeUtf8(payload); } diff --git a/src/runtime/runtime-credential-resolver.service.spec.ts b/src/runtime/runtime-credential-resolver.service.spec.ts index 5b21718..87a3162 100644 --- a/src/runtime/runtime-credential-resolver.service.spec.ts +++ b/src/runtime/runtime-credential-resolver.service.spec.ts @@ -7,7 +7,7 @@ describe('RuntimeCredentialResolverService (single-bearer, CP-9)', () => { runtimeDevAgentId: 'control-plane', runtimeBearerToken: '', runtimeUseDevHeader: false, - ...config, + ...config } as AppConfigService; return new RuntimeCredentialResolverService(merged); } @@ -36,7 +36,7 @@ describe('RuntimeCredentialResolverService (single-bearer, CP-9)', () => { it('does not attach an x-macp-agent-id header when a bearer token is present', async () => { const service = makeService({ runtimeBearerToken: 'obs-token', - runtimeUseDevHeader: true, + runtimeUseDevHeader: true }); const result = await service.resolve({ runtimeKind: 'rust' }); expect(result.metadata['x-macp-agent-id']).toBeUndefined(); @@ -48,7 +48,7 @@ describe('RuntimeCredentialResolverService (single-bearer, CP-9)', () => { const service = makeService({ runtimeBearerToken: '', runtimeUseDevHeader: true, - runtimeDevAgentId: 'control-plane', + runtimeDevAgentId: 'control-plane' }); const result = await service.resolve({ runtimeKind: 'rust' }); expect(result.metadata['x-macp-agent-id']).toBe('control-plane'); @@ -71,7 +71,10 @@ describe('RuntimeCredentialResolverService (single-bearer, CP-9)', () => { runtimeKind: 'rust', // Cast — these fields are no longer accepted, but the resolver must tolerate them // during the deprecation window without routing on them. - ...( { participant: { id: 'risk-agent' }, requester: { actorId: 'user-1' } } as unknown as Record), + ...({ participant: { id: 'risk-agent' }, requester: { actorId: 'user-1' } } as unknown as Record< + string, + unknown + >) } as { runtimeKind: string }); expect(result.metadata.authorization).toBe('Bearer obs-token'); expect(result.sender).toBe('control-plane'); diff --git a/src/runtime/rust-runtime.provider.ts b/src/runtime/rust-runtime.provider.ts index e830aee..963c3da 100644 --- a/src/runtime/rust-runtime.provider.ts +++ b/src/runtime/rust-runtime.provider.ts @@ -25,17 +25,12 @@ import { RuntimeUnregisterPolicyResult, RuntimeGetPolicyRequest, RuntimeListPoliciesRequest, - RuntimePolicyDescriptor + RuntimePolicyDescriptor, + SessionLifecycleEvent } from '../contracts/runtime'; import { InstrumentationService } from '../telemetry/instrumentation.service'; import { CircuitBreaker } from './circuit-breaker'; -import { - buildMetadata, - fromAck, - fromEnvelope, - fromSessionMetadata, - getClientMethod, -} from './grpc-helpers'; +import { buildMetadata, fromAck, fromEnvelope, fromSessionMetadata, getClientMethod } from './grpc-helpers'; import { RuntimeCredentialResolverService } from './runtime-credential-resolver.service'; export interface GrpcCallOptions { @@ -87,7 +82,11 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { this.circuitBreaker = new CircuitBreaker({ failureThreshold: this.config.runtimeCircuitBreakerThreshold, resetTimeoutMs: this.config.runtimeCircuitBreakerResetMs, - instrumentation: this.instrumentation + instrumentation: this.instrumentation, + isExpectedError: (error: unknown) => { + const code = (error as grpc.ServiceError)?.code; + return code === grpc.status.NOT_FOUND || code === grpc.status.PERMISSION_DENIED; + } }); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -110,9 +109,7 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { const descriptor = grpc.loadPackageDefinition(packageDefinition) as any; this.serviceConstructor = descriptor.macp.v1.MACPRuntimeService; this.runtimeAddress = this.config.runtimeAddress; - this.channelCreds = this.config.runtimeTls - ? grpc.credentials.createSsl() - : grpc.credentials.createInsecure(); + this.channelCreds = this.config.runtimeTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure(); this.client = this.createClient(); } @@ -122,26 +119,32 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } async initialize(req: RuntimeInitializeRequest, opts?: GrpcCallOptions): Promise { - const response = await this.unary('Initialize', { - supportedProtocolVersions: ['1.0'], - clientInfo: { - name: req.clientName, - title: req.clientName, - version: req.clientVersion, - description: 'MACP Control Plane (observer)', - websiteUrl: '' + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary( + 'Initialize', + { + supportedProtocolVersions: ['1.0'], + clientInfo: { + name: req.clientName, + title: req.clientName, + version: req.clientVersion, + description: 'MACP Control Plane (observer)', + websiteUrl: '' + }, + capabilities: { + sessions: { stream: true }, + cancellation: { cancelSession: true }, + progress: { progress: true }, + manifest: { getManifest: true }, + modeRegistry: { listModes: true, listChanged: false }, + roots: { listRoots: true, listChanged: false }, + policyRegistry: { registerPolicy: true, listPolicies: true, listChanged: false }, + experimental: { features: {} } + } }, - capabilities: { - sessions: { stream: true }, - cancellation: { cancelSession: true }, - progress: { progress: true }, - manifest: { getManifest: true }, - modeRegistry: { listModes: true, listChanged: false }, - roots: { listRoots: true, listChanged: false }, - policyRegistry: { registerPolicy: true, listPolicies: true, listChanged: false }, - experimental: { features: {} } - } - }, undefined, opts); + buildMetadata(creds.metadata), + opts + ); return { selectedProtocolVersion: response.selectedProtocolVersion, @@ -154,15 +157,17 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { }, supportedModes: response.supportedModes ?? [], instructions: response.instructions || undefined, - capabilities: response.capabilities ? { - sessions: response.capabilities.sessions, - cancellation: response.capabilities.cancellation, - progress: response.capabilities.progress, - manifest: response.capabilities.manifest, - modeRegistry: response.capabilities.modeRegistry, - roots: response.capabilities.roots, - policyRegistry: response.capabilities.policyRegistry - } : undefined + capabilities: response.capabilities + ? { + sessions: response.capabilities.sessions, + cancellation: response.capabilities.cancellation, + progress: response.capabilities.progress, + manifest: response.capabilities.manifest, + modeRegistry: response.capabilities.modeRegistry, + roots: response.capabilities.roots, + policyRegistry: response.capabilities.policyRegistry + } + : undefined }; } @@ -243,8 +248,11 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { // Observer stream: end the write side immediately — we only read. // This tells the runtime the client is a passive subscriber. - try { grpcCall.end(); } catch { /* some gRPC impls no-op on empty streams */ } - + try { + grpcCall.end(); + } catch { + /* some gRPC impls no-op on empty streams */ + } } catch (error) { streamFailure = error instanceof Error ? error : new Error(String(error)); ended = true; @@ -284,7 +292,11 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { }, async return(): Promise> { if (grpcCall) { - try { grpcCall.cancel(); } catch { /* ignore */ } + try { + grpcCall.cancel(); + } catch { + /* ignore */ + } } return { done: true, value: undefined }; } @@ -297,7 +309,11 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { abort: () => { ended = true; if (grpcCall) { - try { grpcCall.cancel(); } catch { /* ignore */ } + try { + grpcCall.cancel(); + } catch { + /* ignore */ + } } notify(); } @@ -306,11 +322,7 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { async getSession(req: RuntimeGetSessionRequest): Promise { const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); - const response = await this.unary( - 'GetSession', - { sessionId: req.runtimeSessionId }, - buildMetadata(creds.metadata) - ); + const response = await this.unary('GetSession', { sessionId: req.runtimeSessionId }, buildMetadata(creds.metadata)); return fromSessionMetadata(response.metadata); } @@ -325,7 +337,8 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } async getManifest(): Promise { - const response = await this.unary('GetManifest', { agentId: '' }); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('GetManifest', { agentId: '' }, buildMetadata(creds.metadata)); return { agentId: response.manifest?.agentId ?? 'macp-runtime', title: response.manifest?.title, @@ -336,7 +349,8 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } async listModes(): Promise { - const response = await this.unary('ListModes', {}); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('ListModes', {}, buildMetadata(creds.metadata)); return (response.modes ?? []).map((mode: any) => ({ mode: mode.mode, modeVersion: mode.modeVersion, @@ -351,7 +365,8 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } async listRoots(): Promise { - const response = await this.unary('ListRoots', {}); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('ListRoots', {}, buildMetadata(creds.metadata)); return (response.roots ?? []).map((root: any) => ({ uri: root.uri, name: root.name })); } @@ -373,29 +388,137 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } } + // ── Session lifecycle observation ───────────────────────────────── + + async listSessions(): Promise { + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('ListSessions', {}, buildMetadata(creds.metadata)); + return (response.sessions ?? []).map((s: any) => fromSessionMetadata(s)); + } + + watchSessions(): AsyncIterable { + // Capture instance deps before the closure so the `this` alias lint rule is satisfied. + const credentialResolver = this.credentialResolver; + const client = this.client; + const kind = this.kind; + + return { + [Symbol.asyncIterator]() { + let grpcCall: any = null; + const buffer: SessionLifecycleEvent[] = []; + let resolveWait: (() => void) | null = null; + let ended = false; + let streamError: Error | null = null; + + const notify = () => { + if (resolveWait) { + const r = resolveWait; + resolveWait = null; + r(); + } + }; + + const launch = async () => { + try { + const creds = await credentialResolver.resolve({ runtimeKind: kind }); + const metadata = buildMetadata(creds.metadata); + const method = getClientMethod(client, 'WatchSessions'); + grpcCall = method.call(client, {}, metadata); + + grpcCall.on('data', (chunk: any) => { + const event = chunk.event; + if (!event) return; + const eventTypeRaw = event.eventType ?? event.event_type ?? ''; + let eventType: 'created' | 'resolved' | 'expired' = 'created'; + if (eventTypeRaw === 'EVENT_TYPE_RESOLVED' || eventTypeRaw === 1) eventType = 'resolved'; + else if (eventTypeRaw === 'EVENT_TYPE_EXPIRED' || eventTypeRaw === 2) eventType = 'expired'; + else if (eventTypeRaw === 'EVENT_TYPE_CREATED' || eventTypeRaw === 0) eventType = 'created'; + + buffer.push({ + eventType, + session: fromSessionMetadata(event.session), + observedAtUnixMs: event.observedAtUnixMs ? Number(event.observedAtUnixMs) : Date.now() + }); + notify(); + }); + + grpcCall.on('error', (err: Error) => { + streamError = err; + ended = true; + notify(); + }); + grpcCall.on('end', () => { + ended = true; + notify(); + }); + } catch (err) { + streamError = err instanceof Error ? err : new Error(String(err)); + ended = true; + notify(); + } + }; + + void launch(); + + return { + async next(): Promise> { + while (true) { + if (buffer.length > 0) return { done: false, value: buffer.shift()! }; + if (ended) { + if (streamError) throw streamError; + return { done: true, value: undefined }; + } + await new Promise((r) => { + if (buffer.length > 0 || ended) r(); + else resolveWait = r; + }); + } + }, + async return(): Promise> { + if (grpcCall) { + try { + grpcCall.cancel(); + } catch { + /* ignore */ + } + } + return { done: true, value: undefined }; + } + }; + } + }; + } + // ── Governance policy lifecycle (RFC-MACP-0012) ────────────────── async registerPolicy(req: RuntimeRegisterPolicyRequest): Promise { + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); const descriptor = req.descriptor; - const response = await this.unary('RegisterPolicy', { - policyDescriptor: { - policyId: descriptor.policyId, - mode: descriptor.mode, - description: descriptor.description, - rules: typeof descriptor.rules === 'string' ? Buffer.from(descriptor.rules) : descriptor.rules, - schemaVersion: descriptor.schemaVersion - } - }); + const response = await this.unary( + 'RegisterPolicy', + { + policyDescriptor: { + policyId: descriptor.policyId, + mode: descriptor.mode, + description: descriptor.description, + rules: typeof descriptor.rules === 'string' ? Buffer.from(descriptor.rules) : descriptor.rules, + schemaVersion: descriptor.schemaVersion + } + }, + buildMetadata(creds.metadata) + ); return { ok: response.ok ?? false, error: response.error || undefined }; } async unregisterPolicy(req: RuntimeUnregisterPolicyRequest): Promise { - const response = await this.unary('UnregisterPolicy', { policyId: req.policyId }); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('UnregisterPolicy', { policyId: req.policyId }, buildMetadata(creds.metadata)); return { ok: response.ok ?? false, error: response.error || undefined }; } async getPolicy(req: RuntimeGetPolicyRequest): Promise { - const response = await this.unary('GetPolicy', { policyId: req.policyId }); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('GetPolicy', { policyId: req.policyId }, buildMetadata(creds.metadata)); const d = response.policyDescriptor ?? response.descriptor; return { policyId: d.policyId, @@ -408,7 +531,8 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } async listPolicies(req?: RuntimeListPoliciesRequest): Promise { - const response = await this.unary('ListPolicies', { mode: req?.mode ?? '' }); + const creds = await this.credentialResolver.resolve({ runtimeKind: this.kind }); + const response = await this.unary('ListPolicies', { mode: req?.mode ?? '' }, buildMetadata(creds.metadata)); return (response.descriptors ?? []).map((d: any) => ({ policyId: d.policyId, mode: d.mode, @@ -442,18 +566,13 @@ export class RustRuntimeProvider implements RuntimeProvider, OnModuleInit { } }); }); - this.instrumentation.grpcCallDuration.observe( - { method, status: 'ok' }, - (Date.now() - start) / 1000 - ); + this.instrumentation.grpcCallDuration.observe({ method, status: 'ok' }, (Date.now() - start) / 1000); return result; } catch (error) { - this.instrumentation.grpcCallDuration.observe( - { method, status: 'error' }, - (Date.now() - start) / 1000 - ); + const grpcErr = error as grpc.ServiceError; + this.logger.error(`gRPC ${method} failed: code=${grpcErr.code} details="${grpcErr.details ?? grpcErr.message}"`); + this.instrumentation.grpcCallDuration.observe({ method, status: 'error' }, (Date.now() - start) / 1000); throw error; } } - } diff --git a/src/storage/artifact.repository.ts b/src/storage/artifact.repository.ts index 5dfbd54..8d4bb0c 100644 --- a/src/storage/artifact.repository.ts +++ b/src/storage/artifact.repository.ts @@ -25,11 +25,7 @@ export class ArtifactRepository { } async findById(id: string) { - const rows = await this.database.db - .select() - .from(runArtifacts) - .where(eq(runArtifacts.id, id)) - .limit(1); + const rows = await this.database.db.select().from(runArtifacts).where(eq(runArtifacts.id, id)).limit(1); return rows[0] ?? null; } diff --git a/src/storage/event.repository.ts b/src/storage/event.repository.ts index 76542d6..3d969ca 100644 --- a/src/storage/event.repository.ts +++ b/src/storage/event.repository.ts @@ -26,39 +26,45 @@ export class EventRepository { async appendRaw(runId: string, seq: number, raw: RawRuntimeEvent, tx?: Tx) { const db = tx ?? this.database.db; - await db.insert(runEventsRaw).values({ - id: randomUUID(), - runId, - seq, - ts: raw.receivedAt, - kind: raw.kind, - sourceName: 'rust-runtime', - payload: this.serializeRaw(raw) - }).onConflictDoNothing(); + await db + .insert(runEventsRaw) + .values({ + id: randomUUID(), + runId, + seq, + ts: raw.receivedAt, + kind: raw.kind, + sourceName: 'rust-runtime', + payload: this.serializeRaw(raw) + }) + .onConflictDoNothing(); } async appendCanonical(events: CanonicalEvent[], tx?: Tx) { if (events.length === 0) return; const db = tx ?? this.database.db; - await db.insert(runEventsCanonical).values( - events.map((event) => ({ - id: event.id, - runId: event.runId, - seq: event.seq, - ts: event.ts, - type: event.type, - subjectKind: event.subject?.kind, - subjectId: event.subject?.id, - sourceKind: event.source.kind, - sourceName: event.source.name, - rawType: event.source.rawType, - traceId: event.trace?.traceId, - spanId: event.trace?.spanId, - parentSpanId: event.trace?.parentSpanId, - data: event.data, - schemaVersion: event.schemaVersion ?? 3 - })) - ).onConflictDoNothing(); + await db + .insert(runEventsCanonical) + .values( + events.map((event) => ({ + id: event.id, + runId: event.runId, + seq: event.seq, + ts: event.ts, + type: event.type, + subjectKind: event.subject?.kind, + subjectId: event.subject?.id, + sourceKind: event.source.kind, + sourceName: event.source.name, + rawType: event.source.rawType, + traceId: event.trace?.traceId, + spanId: event.trace?.spanId, + parentSpanId: event.trace?.parentSpanId, + data: event.data, + schemaVersion: event.schemaVersion ?? 3 + })) + ) + .onConflictDoNothing(); } async listCanonicalByRun(runId: string, afterSeq = 0, limit = 200) { @@ -74,7 +80,9 @@ export class EventRepository { * Filtered query over canonical events — used by the per-run endpoint with time bounds (§4.2) * and the cross-run GET /events endpoint (§4.1). */ - async listCanonicalFiltered(filter: CanonicalEventFilter): Promise<{ data: typeof runEventsCanonical.$inferSelect[]; total: number }> { + async listCanonicalFiltered( + filter: CanonicalEventFilter + ): Promise<{ data: (typeof runEventsCanonical.$inferSelect)[]; total: number }> { const conditions = this.buildCanonicalWhere(filter); const limit = filter.limit ?? 200; const whereClause = conditions.length > 0 ? and(...conditions) : undefined; @@ -105,7 +113,7 @@ export class EventRepository { spanId: runEventsCanonical.spanId, parentSpanId: runEventsCanonical.parentSpanId, data: runEventsCanonical.data, - schemaVersion: runEventsCanonical.schemaVersion, + schemaVersion: runEventsCanonical.schemaVersion }) .from(runEventsCanonical) .innerJoin(runs, eq(runs.id, runEventsCanonical.runId)) @@ -116,9 +124,9 @@ export class EventRepository { .select({ value: count() }) .from(runEventsCanonical) .innerJoin(runs, eq(runs.id, runEventsCanonical.runId)) - .where(composed), + .where(composed) ]); - return { data: data as typeof runEventsCanonical.$inferSelect[], total: Number(totalResult[0]?.value ?? 0) }; + return { data: data as (typeof runEventsCanonical.$inferSelect)[], total: Number(totalResult[0]?.value ?? 0) }; } const [data, totalResult] = await Promise.all([ @@ -128,10 +136,7 @@ export class EventRepository { .where(whereClause) .orderBy(asc(runEventsCanonical.ts), asc(runEventsCanonical.seq)) .limit(limit), - this.database.db - .select({ value: count() }) - .from(runEventsCanonical) - .where(whereClause), + this.database.db.select({ value: count() }).from(runEventsCanonical).where(whereClause) ]); return { data, total: Number(totalResult[0]?.value ?? 0) }; } @@ -174,7 +179,11 @@ export class EventRepository { .limit(limit); } - async *streamCanonicalByRun(runId: string, afterSeq = 0, batchSize = 500): AsyncGenerator { + async *streamCanonicalByRun( + runId: string, + afterSeq = 0, + batchSize = 500 + ): AsyncGenerator { let cursor = afterSeq; while (true) { const batch = await this.database.db @@ -193,9 +202,10 @@ export class EventRepository { } async listCanonicalUpTo(runId: string, seq?: number) { - const where = seq === undefined - ? eq(runEventsCanonical.runId, runId) - : and(eq(runEventsCanonical.runId, runId), lte(runEventsCanonical.seq, seq)); + const where = + seq === undefined + ? eq(runEventsCanonical.runId, runId) + : and(eq(runEventsCanonical.runId, runId), lte(runEventsCanonical.seq, seq)); return this.database.db.select().from(runEventsCanonical).where(where).orderBy(asc(runEventsCanonical.seq)); } diff --git a/src/storage/projection.repository.spec.ts b/src/storage/projection.repository.spec.ts index f8998ec..76d00ab 100644 --- a/src/storage/projection.repository.spec.ts +++ b/src/storage/projection.repository.spec.ts @@ -20,7 +20,7 @@ function makeMockDb() { select: selectFn, // Expose inner mocks for assertions _insert: { values: insertValues, onConflictDoUpdate }, - _select: { from: selectFrom, where: selectWhere, limit: selectLimit }, + _select: { from: selectFrom, where: selectWhere, limit: selectLimit } }; } @@ -36,7 +36,7 @@ function fakeProjection(): RunStateProjection { signals: { items: [] }, timeline: { entries: [] }, trace: { spans: [] }, - progress: { percent: 50 }, + progress: { percent: 50 } } as unknown as RunStateProjection; } @@ -73,7 +73,7 @@ describe('ProjectionRepository', () => { version: 5, schemaVersion: 1, runSummary: { id: 'run-1' }, - participants: [], + participants: [] }; mockDb._select.limit.mockResolvedValue([fakeRow]); @@ -103,16 +103,16 @@ describe('ProjectionRepository', () => { signals: projection.signals, timeline: projection.timeline, traceSummary: projection.trace, - progress: projection.progress, - }), + progress: projection.progress + }) ); expect(mockDb._insert.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ set: expect.objectContaining({ version: 10, - schemaVersion: 2, - }), - }), + schemaVersion: 2 + }) + }) ); }); @@ -123,15 +123,15 @@ describe('ProjectionRepository', () => { expect(mockDb._insert.values).toHaveBeenCalledWith( expect.objectContaining({ - schemaVersion: 0, - }), + schemaVersion: 0 + }) ); expect(mockDb._insert.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ set: expect.objectContaining({ - schemaVersion: 0, - }), - }), + schemaVersion: 0 + }) + }) ); }); @@ -161,8 +161,8 @@ describe('ProjectionRepository', () => { expect(mockDb._insert.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ - setWhere: expect.anything(), - }), + setWhere: expect.anything() + }) ); // Verify the onConflictDoUpdate call has target, set, and setWhere @@ -179,8 +179,8 @@ describe('ProjectionRepository', () => { expect(mockDb._insert.values).toHaveBeenCalledWith( expect.objectContaining({ - updatedAt: expect.any(String), - }), + updatedAt: expect.any(String) + }) ); }); }); diff --git a/src/storage/projection.repository.ts b/src/storage/projection.repository.ts index 064d1ef..c7ed805 100644 --- a/src/storage/projection.repository.ts +++ b/src/storage/projection.repository.ts @@ -13,11 +13,7 @@ export class ProjectionRepository { constructor(private readonly database: DatabaseService) {} async get(runId: string) { - const rows = await this.database.db - .select() - .from(runProjections) - .where(eq(runProjections.runId, runId)) - .limit(1); + const rows = await this.database.db.select().from(runProjections).where(eq(runProjections.runId, runId)).limit(1); return rows[0] ?? null; } diff --git a/src/storage/run.repository.spec.ts b/src/storage/run.repository.spec.ts index 453e674..7cb4914 100644 --- a/src/storage/run.repository.spec.ts +++ b/src/storage/run.repository.spec.ts @@ -104,9 +104,7 @@ describe('RunRepository', () => { mockDb._select.limit.mockResolvedValue([]); await expect(repo.findByIdOrThrow('missing-id')).rejects.toThrow(NotFoundException); - await expect(repo.findByIdOrThrow('missing-id')).rejects.toThrow( - 'run missing-id not found' - ); + await expect(repo.findByIdOrThrow('missing-id')).rejects.toThrow('run missing-id not found'); }); it('returns the run when found', async () => { @@ -137,9 +135,7 @@ describe('RunRepository', () => { const result = await repo.markStarted('run-1'); expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb._update.set).toHaveBeenCalledWith( - expect.objectContaining({ status: 'starting' }) - ); + expect(mockDb._update.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'starting' })); expect(result).toEqual(fakeRun); }); }); @@ -152,9 +148,7 @@ describe('RunRepository', () => { const result = await repo.markCompleted('run-1'); - expect(mockDb._update.set).toHaveBeenCalledWith( - expect.objectContaining({ status: 'completed' }) - ); + expect(mockDb._update.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })); expect(result).toEqual(fakeRun); }); }); @@ -186,9 +180,7 @@ describe('RunRepository', () => { const result = await repo.markCancelled('run-1'); - expect(mockDb._update.set).toHaveBeenCalledWith( - expect.objectContaining({ status: 'cancelled' }) - ); + expect(mockDb._update.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'cancelled' })); expect(result).toEqual(fakeRun); }); }); @@ -221,9 +213,7 @@ describe('RunRepository', () => { // Simulate: update returns 0 rows (no valid source state matched) mockDb._update.returning.mockResolvedValue([]); // findById then returns a run that is already "completed" - mockDb._select.limit.mockResolvedValue([ - { id: 'run-1', status: 'completed' } - ]); + mockDb._select.limit.mockResolvedValue([{ id: 'run-1', status: 'completed' }]); await expect(repo.markRunning('run-1')).rejects.toThrow(ConflictException); await expect(repo.markRunning('run-1')).rejects.toThrow( @@ -235,9 +225,7 @@ describe('RunRepository', () => { mockDb._update.returning.mockResolvedValue([]); mockDb._select.limit.mockResolvedValue([]); - await expect(repo.markStarted('missing-id')).rejects.toThrow( - 'run missing-id not found' - ); + await expect(repo.markStarted('missing-id')).rejects.toThrow('run missing-id not found'); }); it('returns existing run when already in target status', async () => { diff --git a/src/storage/run.repository.ts b/src/storage/run.repository.ts index acde807..7978876 100644 --- a/src/storage/run.repository.ts +++ b/src/storage/run.repository.ts @@ -69,11 +69,12 @@ export class RunRepository { } async findByIdempotencyKey(idempotencyKey: string) { - const rows = await this.database.db - .select() - .from(runs) - .where(eq(runs.idempotencyKey, idempotencyKey)) - .limit(1); + const rows = await this.database.db.select().from(runs).where(eq(runs.idempotencyKey, idempotencyKey)).limit(1); + return rows[0] ?? null; + } + + async findByRuntimeSessionId(runtimeSessionId: string) { + const rows = await this.database.db.select().from(runs).where(eq(runs.runtimeSessionId, runtimeSessionId)).limit(1); return rows[0] ?? null; } @@ -98,11 +99,7 @@ export class RunRepository { return Number(row.last_event_seq) - count + 1; } - private async transitionTo( - id: string, - targetStatus: RunStatus, - patch: Partial - ) { + private async transitionTo(id: string, targetStatus: RunStatus, patch: Partial) { const validFrom = Object.entries(VALID_TRANSITIONS) .filter(([, targets]) => targets.includes(targetStatus)) .map(([from]) => from); @@ -121,9 +118,7 @@ export class RunRepository { const current = await this.findById(id); if (!current) throw new Error(`run ${id} not found`); if (current.status === targetStatus) return current; - throw new ConflictException( - `cannot transition run ${id} from '${current.status}' to '${targetStatus}'` - ); + throw new ConflictException(`cannot transition run ${id} from '${current.status}' to '${targetStatus}'`); } return result[0]; } diff --git a/src/storage/runtime-session.repository.ts b/src/storage/runtime-session.repository.ts index 8058d6d..456aa62 100644 --- a/src/storage/runtime-session.repository.ts +++ b/src/storage/runtime-session.repository.ts @@ -32,11 +32,7 @@ export class RuntimeSessionRepository { } async findByRunId(runId: string) { - const rows = await this.database.db - .select() - .from(runtimeSessions) - .where(eq(runtimeSessions.runId, runId)) - .limit(1); + const rows = await this.database.db.select().from(runtimeSessions).where(eq(runtimeSessions.runId, runId)).limit(1); return rows[0] ?? null; } diff --git a/src/telemetry/instrumentation.service.spec.ts b/src/telemetry/instrumentation.service.spec.ts index 29c6b34..1cc6784 100644 --- a/src/telemetry/instrumentation.service.spec.ts +++ b/src/telemetry/instrumentation.service.spec.ts @@ -6,17 +6,17 @@ jest.mock('prom-client', () => { const mockHistogram = jest.fn().mockImplementation(() => ({ observe: jest.fn(), labels: jest.fn().mockReturnThis(), - startTimer: jest.fn(), + startTimer: jest.fn() })); const mockCounter = jest.fn().mockImplementation(() => ({ inc: jest.fn(), - labels: jest.fn().mockReturnThis(), + labels: jest.fn().mockReturnThis() })); const mockGauge = jest.fn().mockImplementation(() => ({ set: jest.fn(), inc: jest.fn(), dec: jest.fn(), - labels: jest.fn().mockReturnThis(), + labels: jest.fn().mockReturnThis() })); return { @@ -26,8 +26,8 @@ jest.mock('prom-client', () => { collectDefaultMetrics: jest.fn(), register: { metrics: jest.fn().mockResolvedValue('# HELP fake_metric\nfake_metric 1'), - contentType: 'text/plain; version=0.0.4; charset=utf-8', - }, + contentType: 'text/plain; version=0.0.4; charset=utf-8' + } }; }); @@ -96,9 +96,7 @@ describe('InstrumentationService', () => { it('httpRequestDuration is a histogram and observes', () => { assertMetricShape(service.httpRequestDuration, 'histogram'); - expect(() => - service.httpRequestDuration.observe({ method: 'GET', status_code: '200' }, 0.1), - ).not.toThrow(); + expect(() => service.httpRequestDuration.observe({ method: 'GET', status_code: '200' }, 0.1)).not.toThrow(); }); it('httpRequestsTotal is a counter and increments', () => { @@ -123,9 +121,7 @@ describe('InstrumentationService', () => { it('grpcCallDuration is a histogram with method + status labels', () => { assertMetricShape(service.grpcCallDuration, 'histogram'); - expect(() => - service.grpcCallDuration.observe({ method: 'Initialize', status: 'ok' }, 0.05), - ).not.toThrow(); + expect(() => service.grpcCallDuration.observe({ method: 'Initialize', status: 'ok' }, 0.05)).not.toThrow(); }); it('circuitBreakerState is a gauge', () => { @@ -142,9 +138,7 @@ describe('InstrumentationService', () => { it('outboundMessagesTotal is a counter with category + status labels', () => { assertMetricShape(service.outboundMessagesTotal, 'counter'); - expect(() => - service.outboundMessagesTotal.inc({ category: 'observer', status: 'subscribed' }), - ).not.toThrow(); + expect(() => service.outboundMessagesTotal.inc({ category: 'observer', status: 'subscribed' })).not.toThrow(); }); it('inboundMessagesTotal is a counter', () => { @@ -175,39 +169,27 @@ describe('InstrumentationService', () => { // =========================================================================== describe('metric registration names', () => { it('should register httpRequestDuration with correct name', () => { - expect(client.Histogram).toHaveBeenCalledWith( - expect.objectContaining({ name: 'http_request_duration_seconds' }), - ); + expect(client.Histogram).toHaveBeenCalledWith(expect.objectContaining({ name: 'http_request_duration_seconds' })); }); it('should register httpRequestsTotal with correct name', () => { - expect(client.Counter).toHaveBeenCalledWith( - expect.objectContaining({ name: 'http_requests_total' }), - ); + expect(client.Counter).toHaveBeenCalledWith(expect.objectContaining({ name: 'http_requests_total' })); }); it('should register grpcCallDuration with correct name', () => { - expect(client.Histogram).toHaveBeenCalledWith( - expect.objectContaining({ name: 'grpc_call_duration_seconds' }), - ); + expect(client.Histogram).toHaveBeenCalledWith(expect.objectContaining({ name: 'grpc_call_duration_seconds' })); }); it('should register activeSseConnections with correct name', () => { - expect(client.Gauge).toHaveBeenCalledWith( - expect.objectContaining({ name: 'active_sse_connections' }), - ); + expect(client.Gauge).toHaveBeenCalledWith(expect.objectContaining({ name: 'active_sse_connections' })); }); it('should register activeStreams with correct name', () => { - expect(client.Gauge).toHaveBeenCalledWith( - expect.objectContaining({ name: 'active_runtime_streams' }), - ); + expect(client.Gauge).toHaveBeenCalledWith(expect.objectContaining({ name: 'active_runtime_streams' })); }); it('should register circuitBreakerState with correct name', () => { - expect(client.Gauge).toHaveBeenCalledWith( - expect.objectContaining({ name: 'circuit_breaker_state' }), - ); + expect(client.Gauge).toHaveBeenCalledWith(expect.objectContaining({ name: 'circuit_breaker_state' })); }); }); }); diff --git a/src/telemetry/redaction.service.spec.ts b/src/telemetry/redaction.service.spec.ts index 1a24904..eca08bd 100644 --- a/src/telemetry/redaction.service.spec.ts +++ b/src/telemetry/redaction.service.spec.ts @@ -23,12 +23,12 @@ describe('RedactionService (§8.3)', () => { const out = svc.redact({ a: 'my secret value', b: ['no secret', 'clean'], - nested: { c: 'secret inside', d: 42 }, + nested: { c: 'secret inside', d: 42 } }); expect(out).toEqual({ a: 'my [REDACTED] value', b: ['no [REDACTED]', 'clean'], - nested: { c: '[REDACTED] inside', d: 42 }, + nested: { c: '[REDACTED] inside', d: 42 } }); }); diff --git a/src/telemetry/redaction.service.ts b/src/telemetry/redaction.service.ts index fba91ce..62af5cf 100644 --- a/src/telemetry/redaction.service.ts +++ b/src/telemetry/redaction.service.ts @@ -65,7 +65,9 @@ export class RedactionService { try { out.push(new RegExp(trimmed, 'g')); } catch (err) { - this.logger.warn(`Ignoring invalid redaction pattern ${JSON.stringify(trimmed)}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Ignoring invalid redaction pattern ${JSON.stringify(trimmed)}: ${err instanceof Error ? err.message : String(err)}` + ); } } return out; diff --git a/src/telemetry/trace.service.spec.ts b/src/telemetry/trace.service.spec.ts index dfbd578..f873d0b 100644 --- a/src/telemetry/trace.service.spec.ts +++ b/src/telemetry/trace.service.spec.ts @@ -1,4 +1,3 @@ - // --------------------------------------------------------------------------- // Mock @opentelemetry/api — all mock references live inside the factory // so they are available when Jest hoists the mock call. @@ -13,7 +12,7 @@ const otelMocks = { startSpan: jest.fn(), with: jest.fn(), active: jest.fn().mockReturnValue({}), - setSpan: jest.fn().mockReturnValue({}), + setSpan: jest.fn().mockReturnValue({}) }; // Build the span object that startSpan returns @@ -23,7 +22,7 @@ function makeSpan() { setAttribute: otelMocks.setAttribute, setStatus: otelMocks.setStatus, recordException: otelMocks.recordException, - spanContext: otelMocks.spanContext, + spanContext: otelMocks.spanContext }; } @@ -33,16 +32,16 @@ otelMocks.with.mockImplementation((_ctx: any, fn: any) => fn()); jest.mock('@opentelemetry/api', () => ({ trace: { getTracer: jest.fn().mockReturnValue({ startSpan: otelMocks.startSpan }), - setSpan: otelMocks.setSpan, + setSpan: otelMocks.setSpan }, context: { with: otelMocks.with, - active: otelMocks.active, + active: otelMocks.active }, SpanStatusCode: { OK: 1, - ERROR: 2, - }, + ERROR: 2 + } })); // Import AFTER mock is set up @@ -78,11 +77,7 @@ describe('TraceService', () => { }); it('sets multiple attributes on the span', async () => { - await service.withSpan( - 'multi.attr', - { a: 'alpha', b: 123, c: true }, - async () => 'ok', - ); + await service.withSpan('multi.attr', { a: 'alpha', b: 123, c: true }, async () => 'ok'); expect(otelMocks.setAttribute).toHaveBeenCalledWith('a', 'alpha'); expect(otelMocks.setAttribute).toHaveBeenCalledWith('b', 123); @@ -90,11 +85,7 @@ describe('TraceService', () => { }); it('skips undefined attribute values', async () => { - await service.withSpan( - 'skip.undef', - { defined: 'yes', missing: undefined }, - async () => 'ok', - ); + await service.withSpan('skip.undef', { defined: 'yes', missing: undefined }, async () => 'ok'); expect(otelMocks.setAttribute).toHaveBeenCalledWith('defined', 'yes'); expect(otelMocks.setAttribute).not.toHaveBeenCalledWith('missing', expect.anything()); @@ -109,13 +100,13 @@ describe('TraceService', () => { await expect( service.withSpan('fail.op', {}, async () => { throw error; - }), + }) ).rejects.toThrow('boom'); expect(otelMocks.recordException).toHaveBeenCalledWith(error); expect(otelMocks.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, - message: 'boom', + message: 'boom' }); expect(otelMocks.end).toHaveBeenCalledTimes(1); }); @@ -125,13 +116,11 @@ describe('TraceService', () => { throw 'string-error'; }); - await expect( - service.withSpan('fail.string', {}, async () => 'unreachable'), - ).rejects.toBe('string-error'); + await expect(service.withSpan('fail.string', {}, async () => 'unreachable')).rejects.toBe('string-error'); expect(otelMocks.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, - message: 'string-error', + message: 'string-error' }); expect(otelMocks.end).toHaveBeenCalledTimes(1); }); @@ -141,9 +130,7 @@ describe('TraceService', () => { throw new Error('fail'); }); - await expect( - service.withSpan('always.end', {}, async () => 'x'), - ).rejects.toThrow('fail'); + await expect(service.withSpan('always.end', {}, async () => 'x')).rejects.toThrow('fail'); expect(otelMocks.end).toHaveBeenCalledTimes(1); }); @@ -205,7 +192,7 @@ describe('TraceService', () => { expect(otelMocks.setAttribute).toHaveBeenCalledWith('run.terminal_status', 'failed'); expect(otelMocks.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, - message: 'runtime crashed', + message: 'runtime crashed' }); expect(otelMocks.recordException).toHaveBeenCalledWith(new Error('runtime crashed')); expect(otelMocks.end).toHaveBeenCalledTimes(1); @@ -219,7 +206,7 @@ describe('TraceService', () => { expect(otelMocks.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, - message: 'run failed', + message: 'run failed' }); // No recordException when error string is undefined expect(otelMocks.recordException).not.toHaveBeenCalled(); @@ -281,7 +268,9 @@ describe('TraceService', () => { const boom = new Error('boom'); await expect( - service.withRunSpan('run-err', 'child.fail', {}, async () => { throw boom; }) + service.withRunSpan('run-err', 'child.fail', {}, async () => { + throw boom; + }) ).rejects.toBe(boom); expect(otelMocks.recordException).toHaveBeenCalledWith(boom); @@ -298,7 +287,7 @@ describe('TraceService', () => { const addEvent = jest.fn(); otelMocks.startSpan.mockReturnValueOnce({ ...makeSpan(), - addEvent, + addEvent } as any); service.startRunTrace('run-e', {}); diff --git a/src/telemetry/trace.service.ts b/src/telemetry/trace.service.ts index bb43fef..906e7f5 100644 --- a/src/telemetry/trace.service.ts +++ b/src/telemetry/trace.service.ts @@ -62,7 +62,11 @@ export class TraceService { } /** Add a span event (timestamped annotation) to the active run span, if any. */ - addRunSpanEvent(runId: string, name: string, attributes?: Record): void { + addRunSpanEvent( + runId: string, + name: string, + attributes?: Record + ): void { const span = this.runSpans.get(runId); if (!span) return; const clean: Record = {}; diff --git a/src/webhooks/webhook-delivery.repository.ts b/src/webhooks/webhook-delivery.repository.ts index 2fa75c3..6e6e7a0 100644 --- a/src/webhooks/webhook-delivery.repository.ts +++ b/src/webhooks/webhook-delivery.repository.ts @@ -8,12 +8,7 @@ import { webhookDeliveries } from '../db/schema'; export class WebhookDeliveryRepository { constructor(private readonly database: DatabaseService) {} - async create(input: { - webhookId: string; - event: string; - runId: string; - payload: Record; - }) { + async create(input: { webhookId: string; event: string; runId: string; payload: Record }) { const id = randomUUID(); const now = new Date().toISOString(); await this.database.db.insert(webhookDeliveries).values({ @@ -58,16 +53,10 @@ export class WebhookDeliveryRepository { } async listPending() { - return this.database.db - .select() - .from(webhookDeliveries) - .where(eq(webhookDeliveries.status, 'pending')); + return this.database.db.select().from(webhookDeliveries).where(eq(webhookDeliveries.status, 'pending')); } async listByWebhookId(webhookId: string) { - return this.database.db - .select() - .from(webhookDeliveries) - .where(eq(webhookDeliveries.webhookId, webhookId)); + return this.database.db.select().from(webhookDeliveries).where(eq(webhookDeliveries.webhookId, webhookId)); } } diff --git a/src/webhooks/webhook.repository.ts b/src/webhooks/webhook.repository.ts index c86ecbb..fc150f3 100644 --- a/src/webhooks/webhook.repository.ts +++ b/src/webhooks/webhook.repository.ts @@ -35,20 +35,14 @@ export class WebhookRepository { return this.database.db.select().from(webhooks); } - async update( - id: string, - fields: { url?: string; events?: string[]; secret?: string; active?: boolean } - ) { + async update(id: string, fields: { url?: string; events?: string[]; secret?: string; active?: boolean }) { const updates: Record = { updatedAt: new Date().toISOString() }; if (fields.url !== undefined) updates.url = fields.url; if (fields.events !== undefined) updates.events = fields.events; if (fields.secret !== undefined) updates.secret = fields.secret; if (fields.active !== undefined) updates.active = fields.active; - await this.database.db - .update(webhooks) - .set(updates) - .where(eq(webhooks.id, id)); + await this.database.db.update(webhooks).set(updates).where(eq(webhooks.id, id)); return this.findById(id); } diff --git a/src/webhooks/webhook.service.spec.ts b/src/webhooks/webhook.service.spec.ts index 7745067..9df5123 100644 --- a/src/webhooks/webhook.service.spec.ts +++ b/src/webhooks/webhook.service.spec.ts @@ -17,7 +17,7 @@ describe('WebhookService', () => { runId: 'run-1', status: 'completed', timestamp: '2026-04-07T00:00:00.000Z', - data: { result: 'success' }, + data: { result: 'success' } }; const makeWebhook = (overrides?: Record) => ({ @@ -28,7 +28,7 @@ describe('WebhookService', () => { active: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - ...overrides, + ...overrides }); const makeDelivery = (overrides?: Record) => ({ @@ -40,14 +40,14 @@ describe('WebhookService', () => { status: 'pending', attempts: 0, createdAt: '2026-04-07T00:00:00.000Z', - ...overrides, + ...overrides }); beforeEach(async () => { jest.useFakeTimers(); instrumentation = { - webhookDeliveriesTotal: { inc: jest.fn() }, + webhookDeliveriesTotal: { inc: jest.fn() } }; const module: TestingModule = await Test.createTestingModule({ @@ -61,8 +61,8 @@ describe('WebhookService', () => { listActive: jest.fn(), findById: jest.fn(), update: jest.fn(), - delete: jest.fn(), - }, + delete: jest.fn() + } }, { provide: WebhookDeliveryRepository, @@ -70,14 +70,14 @@ describe('WebhookService', () => { create: jest.fn(), markDelivered: jest.fn(), markFailed: jest.fn(), - listPending: jest.fn(), - }, + listPending: jest.fn() + } }, { provide: InstrumentationService, - useValue: instrumentation, - }, - ], + useValue: instrumentation + } + ] }).compile(); service = module.get(WebhookService); @@ -87,7 +87,7 @@ describe('WebhookService', () => { // Mock global fetch fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, - status: 200, + status: 200 } as Response); }); @@ -163,7 +163,7 @@ describe('WebhookService', () => { webhookId: 'wh-match', event: 'run.completed', runId: 'run-1', - payload: samplePayload as unknown as Record, + payload: samplePayload as unknown as Record }); }); @@ -175,9 +175,7 @@ describe('WebhookService', () => { await service.fireEvent(samplePayload); expect(deliveryRepository.create).toHaveBeenCalledTimes(1); - expect(deliveryRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ webhookId: 'wh-wildcard' }), - ); + expect(deliveryRepository.create).toHaveBeenCalledWith(expect.objectContaining({ webhookId: 'wh-wildcard' })); }); it('should create delivery records before attempting delivery', async () => { @@ -247,10 +245,10 @@ describe('WebhookService', () => { headers: expect.objectContaining({ 'Content-Type': 'application/json', 'X-MACP-Signature': expectedSignature, - 'X-MACP-Event': 'run.completed', + 'X-MACP-Event': 'run.completed' }), - body, - }), + body + }) ); }); diff --git a/src/webhooks/webhook.service.ts b/src/webhooks/webhook.service.ts index 4326480..107c509 100644 --- a/src/webhooks/webhook.service.ts +++ b/src/webhooks/webhook.service.ts @@ -30,10 +30,7 @@ export class WebhookService { return this.webhookRepository.list(); } - async update( - id: string, - fields: { url?: string; events?: string[]; secret?: string; active?: boolean } - ) { + async update(id: string, fields: { url?: string; events?: string[]; secret?: string; active?: boolean }) { return this.webhookRepository.update(id, fields); } @@ -43,9 +40,7 @@ export class WebhookService { async fireEvent(payload: WebhookPayload): Promise { const activeWebhooks = await this.webhookRepository.listActive(); - const matching = activeWebhooks.filter( - (wh) => wh.events.length === 0 || wh.events.includes(payload.event) - ); + const matching = activeWebhooks.filter((wh) => wh.events.length === 0 || wh.events.includes(payload.event)); for (const webhook of matching) { // Outbox pattern: insert delivery record first, then attempt delivery @@ -110,9 +105,7 @@ export class WebhookService { return; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.warn( - `webhook delivery to ${url} failed (attempt ${attempt}/${maxAttempts}): ${errorMessage}` - ); + this.logger.warn(`webhook delivery to ${url} failed (attempt ${attempt}/${maxAttempts}): ${errorMessage}`); await this.deliveryRepository.markFailed(deliveryId, attempt, errorMessage); if (attempt >= maxAttempts) { this.instrumentation.webhookDeliveriesTotal.inc({ status: 'failed' }); diff --git a/test/helpers/scripted-mock-runtime.provider.ts b/test/helpers/scripted-mock-runtime.provider.ts index fb9e6eb..2cea619 100644 --- a/test/helpers/scripted-mock-runtime.provider.ts +++ b/test/helpers/scripted-mock-runtime.provider.ts @@ -23,6 +23,7 @@ import { RuntimeSubscribeSessionRequest, RuntimeUnregisterPolicyRequest, RuntimeUnregisterPolicyResult, + SessionLifecycleEvent, } from '../../src/contracts/runtime'; export interface ScriptedEvent { @@ -244,6 +245,17 @@ export class ScriptedMockRuntimeProvider implements RuntimeProvider { return all; } + // ── Session lifecycle observation (stub) ──────────────────────────── + + async listSessions(): Promise { + return []; + } + + async *watchSessions(): AsyncIterable { + // Scripted mock does not produce session lifecycle events — tests that need + // them should use a dedicated fixture or override this method. + } + private makeAck(sessionId: string, messageId?: string): RuntimeAck { return { ok: true,