Skip to content

Commit d769d9b

Browse files
authored
Merge pull request #1 from multiagentcoordinationprotocol/sse-resume-recovery-change
v0.2.0: Add SSE resume, run export/compare, and crash recovery
2 parents f651bde + 08e6d8a commit d769d9b

24 files changed

Lines changed: 1113 additions & 28 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ STREAM_IDLE_TIMEOUT_MS=120000
3737
STREAM_MAX_RETRIES=5
3838
STREAM_BACKOFF_BASE_MS=250
3939
STREAM_BACKOFF_MAX_MS=30000
40+
STREAM_SSE_HEARTBEAT_MS=15000
4041

4142
# ── Replay ───────────────────────────────────────
4243
REPLAY_MAX_DELAY_MS=2000
4344
REPLAY_BATCH_SIZE=500
4445

46+
# ── Run Recovery ────────────────────────────────
47+
RUN_RECOVERY_ENABLED=true
48+
4549
# ── Logging ──────────────────────────────────────
4650
LOG_LEVEL=info
4751

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE "run_projections" ADD COLUMN IF NOT EXISTS "progress" jsonb NOT NULL DEFAULT '{"entries":[]}';
2+
CREATE INDEX IF NOT EXISTS run_events_raw_run_ts_idx ON run_events_raw(run_id, ts);
3+
CREATE INDEX IF NOT EXISTS run_events_raw_run_seq_idx ON run_events_raw(run_id, seq);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "macp-control-plane",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"private": true,
55
"description": "Scenario-agnostic control plane for the MACP runtime, built with NestJS.",
66
"license": "MIT",

src/app.module.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ConfigModule } from './config/config.module';
88
import { HealthController } from './controllers/health.controller';
99
import { MetricsController } from './controllers/metrics.controller';
1010
import { ObservabilityController } from './controllers/observability.controller';
11+
import { RunInsightsController } from './controllers/run-insights.controller';
1112
import { RunsController } from './controllers/runs.controller';
1213
import { RuntimeController } from './controllers/runtime.controller';
1314
import { DatabaseModule } from './db/database.module';
@@ -33,8 +34,10 @@ import { RunRepository } from './storage/run.repository';
3334
import { RuntimeSessionRepository } from './storage/runtime-session.repository';
3435
import { InstrumentationService } from './telemetry/instrumentation.service';
3536
import { TraceService } from './telemetry/trace.service';
37+
import { RunInsightsService } from './insights/run-insights.service';
3638
import { RunExecutorService } from './runs/run-executor.service';
3739
import { RunManagerService } from './runs/run-manager.service';
40+
import { RunRecoveryService } from './runs/run-recovery.service';
3841
import { StreamConsumerService } from './runs/stream-consumer.service';
3942

4043
@Module({
@@ -44,7 +47,7 @@ import { StreamConsumerService } from './runs/stream-consumer.service';
4447
AuthModule,
4548
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])
4649
],
47-
controllers: [RunsController, RuntimeController, ObservabilityController, HealthController, MetricsController],
50+
controllers: [RunsController, RunInsightsController, RuntimeController, ObservabilityController, HealthController, MetricsController],
4851
providers: [
4952
{ provide: APP_GUARD, useClass: AuthGuard },
5053
{ provide: APP_GUARD, useClass: ThrottleByUserGuard },
@@ -70,7 +73,9 @@ import { StreamConsumerService } from './runs/stream-consumer.service';
7073
ReplayService,
7174
RunManagerService,
7275
StreamConsumerService,
73-
RunExecutorService
76+
RunExecutorService,
77+
RunRecoveryService,
78+
RunInsightsService
7479
]
7580
})
7681
export class AppModule implements NestModule {

src/config/app-config.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ export class AppConfigService implements OnModuleInit {
5959
readonly streamMaxRetries = readNumber('STREAM_MAX_RETRIES', 5);
6060
readonly streamBackoffBaseMs = readNumber('STREAM_BACKOFF_BASE_MS', 250);
6161
readonly streamBackoffMaxMs = readNumber('STREAM_BACKOFF_MAX_MS', 30000);
62+
readonly streamSseHeartbeatMs = readNumber('STREAM_SSE_HEARTBEAT_MS', 15000);
6263
readonly replayMaxDelayMs = readNumber('REPLAY_MAX_DELAY_MS', 2000);
6364
readonly replayBatchSize = readNumber('REPLAY_BATCH_SIZE', 500);
65+
readonly runRecoveryEnabled = readBoolean('RUN_RECOVERY_ENABLED', true);
6466

6567
readonly dbPoolMax = readNumber('DB_POOL_MAX', 20);
6668
readonly dbPoolIdleTimeout = readNumber('DB_POOL_IDLE_TIMEOUT', 30000);

src/contracts/control-plane.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,39 @@ export interface ReplayRequest {
254254
toSeq?: number;
255255
}
256256

257+
export interface RunExportBundle {
258+
run: Run;
259+
session: Record<string, unknown> | null;
260+
projection: RunStateProjection | null;
261+
metrics: MetricsSummary | null;
262+
artifacts: Artifact[];
263+
canonicalEvents: CanonicalEvent[];
264+
rawEvents: Record<string, unknown>[];
265+
exportedAt: string;
266+
}
267+
268+
export interface RunComparisonRequest {
269+
leftRunId: string;
270+
rightRunId: string;
271+
}
272+
273+
export interface RunComparisonResult {
274+
left: { runId: string; status: RunStatus; modeName?: string; durationMs?: number };
275+
right: { runId: string; status: RunStatus; modeName?: string; durationMs?: number };
276+
statusMatch: boolean;
277+
durationDeltaMs?: number;
278+
confidenceDelta?: number;
279+
participantsDiff: {
280+
added: string[];
281+
removed: string[];
282+
common: string[];
283+
};
284+
signalsDiff: {
285+
added: string[];
286+
removed: string[];
287+
};
288+
}
289+
257290
export interface MetricsSummary {
258291
runId: string;
259292
eventCount: number;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { RunInsightsController } from './run-insights.controller';
2+
import { RunInsightsService } from '../insights/run-insights.service';
3+
4+
describe('RunInsightsController', () => {
5+
let controller: RunInsightsController;
6+
let mockInsightsService: {
7+
exportRun: jest.Mock;
8+
compareRuns: jest.Mock;
9+
};
10+
11+
beforeEach(() => {
12+
mockInsightsService = {
13+
exportRun: jest.fn(),
14+
compareRuns: jest.fn()
15+
};
16+
controller = new RunInsightsController(
17+
mockInsightsService as unknown as RunInsightsService
18+
);
19+
});
20+
21+
describe('exportRun', () => {
22+
it('delegates to insightsService.exportRun with options', async () => {
23+
const bundle = { run: { id: 'run-1' }, exportedAt: '2026-01-01T00:00:00Z' };
24+
mockInsightsService.exportRun.mockResolvedValue(bundle);
25+
26+
const query = { includeCanonical: true, includeRaw: false, eventLimit: 500 };
27+
const result = await controller.exportRun('run-1', query as any);
28+
29+
expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-1', {
30+
includeCanonical: true,
31+
includeRaw: false,
32+
eventLimit: 500
33+
});
34+
expect(result).toEqual(bundle);
35+
});
36+
37+
it('passes undefined options when query is empty', async () => {
38+
mockInsightsService.exportRun.mockResolvedValue({});
39+
40+
await controller.exportRun('run-1', {} as any);
41+
42+
expect(mockInsightsService.exportRun).toHaveBeenCalledWith('run-1', {
43+
includeCanonical: undefined,
44+
includeRaw: undefined,
45+
eventLimit: undefined
46+
});
47+
});
48+
});
49+
50+
describe('compareRuns', () => {
51+
it('delegates to insightsService.compareRuns', async () => {
52+
const comparison = { statusMatch: true, left: {}, right: {} };
53+
mockInsightsService.compareRuns.mockResolvedValue(comparison);
54+
55+
const body = { leftRunId: 'run-1', rightRunId: 'run-2' };
56+
const result = await controller.compareRuns(body as any);
57+
58+
expect(mockInsightsService.compareRuns).toHaveBeenCalledWith('run-1', 'run-2');
59+
expect(result).toEqual(comparison);
60+
});
61+
});
62+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
Body,
3+
Controller,
4+
Get,
5+
Param,
6+
ParseUUIDPipe,
7+
Post,
8+
Query,
9+
ValidationPipe
10+
} from '@nestjs/common';
11+
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
12+
import { CompareRunsDto } from '../dto/compare-runs.dto';
13+
import { ExportRunQueryDto } from '../dto/export-run-query.dto';
14+
import { RunBundleExportDto, RunComparisonResultDto } from '../dto/run-responses.dto';
15+
import { RunInsightsService } from '../insights/run-insights.service';
16+
17+
@ApiTags('runs')
18+
@Controller('runs')
19+
export class RunInsightsController {
20+
constructor(private readonly insightsService: RunInsightsService) {}
21+
22+
@Get(':id/export')
23+
@ApiOperation({ summary: 'Export a full run bundle (run, session, projection, events, artifacts).' })
24+
@ApiOkResponse({ type: RunBundleExportDto })
25+
async exportRun(
26+
@Param('id', new ParseUUIDPipe()) id: string,
27+
@Query(new ValidationPipe({ transform: true, whitelist: true })) query: ExportRunQueryDto
28+
) {
29+
return this.insightsService.exportRun(id, {
30+
includeCanonical: query.includeCanonical,
31+
includeRaw: query.includeRaw,
32+
eventLimit: query.eventLimit
33+
});
34+
}
35+
36+
@Post('compare')
37+
@ApiOperation({ summary: 'Compare two runs side-by-side.' })
38+
@ApiBody({ type: CompareRunsDto })
39+
@ApiOkResponse({ type: RunComparisonResultDto })
40+
async compareRuns(
41+
@Body(new ValidationPipe({ transform: true, whitelist: true })) body: CompareRunsDto
42+
) {
43+
return this.insightsService.compareRuns(body.leftRunId, body.rightRunId);
44+
}
45+
}

src/controllers/runs.controller.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RunManagerService } from '../runs/run-manager.service';
44
import { EventRepository } from '../storage/event.repository';
55
import { ReplayService } from '../replay/replay.service';
66
import { StreamHubService } from '../events/stream-hub.service';
7+
import { AppConfigService } from '../config/app-config.service';
78

89
describe('RunsController', () => {
910
let controller: RunsController;
@@ -23,6 +24,7 @@ describe('RunsController', () => {
2324
};
2425
let mockReplayService: Partial<ReplayService>;
2526
let mockStreamHub: Partial<StreamHubService>;
27+
let mockConfig: Partial<AppConfigService>;
2628

2729
beforeEach(() => {
2830
mockRunExecutor = {
@@ -41,13 +43,17 @@ describe('RunsController', () => {
4143
};
4244
mockReplayService = {};
4345
mockStreamHub = {};
46+
mockConfig = {
47+
streamSseHeartbeatMs: 15000,
48+
};
4449

4550
controller = new RunsController(
4651
mockRunExecutor as unknown as RunExecutorService,
4752
mockRunManager as unknown as RunManagerService,
4853
mockEventRepository as unknown as EventRepository,
4954
mockReplayService as unknown as ReplayService,
5055
mockStreamHub as unknown as StreamHubService,
56+
mockConfig as unknown as AppConfigService,
5157
);
5258
});
5359

0 commit comments

Comments
 (0)