Skip to content

Commit 176b836

Browse files
authored
Merge pull request #2 from multiagentcoordinationprotocol/fix-runtime-protocol
v0.3.0: Fix runtime protocol, add production features and API complet…
2 parents d769d9b + 16ebf70 commit 176b836

42 files changed

Lines changed: 1249 additions & 140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

drizzle/0004_session_ttl.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Add expires_at column to runtime_sessions for TTL tracking
2+
ALTER TABLE "runtime_sessions" ADD COLUMN "expires_at" TIMESTAMP WITH TIME ZONE;

drizzle/0005_webhooks.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Create webhooks table for webhook notification subscriptions
2+
CREATE TABLE IF NOT EXISTS "webhooks" (
3+
"id" UUID PRIMARY KEY,
4+
"url" TEXT NOT NULL,
5+
"events" JSONB NOT NULL DEFAULT '[]',
6+
"secret" VARCHAR(255) NOT NULL,
7+
"active" INTEGER NOT NULL DEFAULT 1,
8+
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
9+
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
10+
);
11+
12+
CREATE INDEX IF NOT EXISTS "webhooks_active_idx" ON "webhooks" ("active");

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "macp-control-plane",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"private": true,
55
"description": "Scenario-agnostic control plane for the MACP runtime, built with NestJS.",
66
"license": "MIT",
@@ -17,7 +17,8 @@
1717
"test:cov": "jest --coverage",
1818
"drizzle:generate": "drizzle-kit generate",
1919
"drizzle:migrate": "drizzle-kit migrate",
20-
"drizzle:studio": "drizzle-kit studio"
20+
"drizzle:studio": "drizzle-kit studio",
21+
"proto:sync": "bash scripts/proto-sync.sh"
2122
},
2223
"dependencies": {
2324
"@grpc/grpc-js": "^1.14.0",

scripts/proto-sync.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
# Sync proto files from the runtime's authoritative protos to the control plane.
3+
# Usage: npm run proto:sync
4+
#
5+
# Assumes the runtime repo is at ../runtime relative to the control plane root.
6+
7+
set -euo pipefail
8+
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
CP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11+
RUNTIME_ROOT="${RUNTIME_PROTO_DIR:-$CP_ROOT/../runtime}"
12+
13+
if [ ! -d "$RUNTIME_ROOT/proto" ]; then
14+
echo "ERROR: Runtime proto directory not found at $RUNTIME_ROOT/proto"
15+
echo "Set RUNTIME_PROTO_DIR to the runtime repo root, or place it at ../runtime"
16+
exit 1
17+
fi
18+
19+
echo "Syncing protos from $RUNTIME_ROOT/proto → $CP_ROOT/proto"
20+
rsync -av --delete "$RUNTIME_ROOT/proto/" "$CP_ROOT/proto/"
21+
echo "Proto sync complete."

src/app.module.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { AuthGuard } from './auth/auth.guard';
55
import { AuthModule } from './auth/auth.module';
66
import { ThrottleByUserGuard } from './auth/throttle-by-user.guard';
77
import { ConfigModule } from './config/config.module';
8+
import { AdminController } from './controllers/admin.controller';
9+
import { AuditController } from './controllers/audit.controller';
810
import { HealthController } from './controllers/health.controller';
911
import { MetricsController } from './controllers/metrics.controller';
1012
import { ObservabilityController } from './controllers/observability.controller';
@@ -39,6 +41,9 @@ import { RunExecutorService } from './runs/run-executor.service';
3941
import { RunManagerService } from './runs/run-manager.service';
4042
import { RunRecoveryService } from './runs/run-recovery.service';
4143
import { StreamConsumerService } from './runs/stream-consumer.service';
44+
import { WebhookController } from './controllers/webhook.controller';
45+
import { WebhookRepository } from './webhooks/webhook.repository';
46+
import { WebhookService } from './webhooks/webhook.service';
4247

4348
@Module({
4449
imports: [
@@ -47,7 +52,7 @@ import { StreamConsumerService } from './runs/stream-consumer.service';
4752
AuthModule,
4853
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])
4954
],
50-
controllers: [RunsController, RunInsightsController, RuntimeController, ObservabilityController, HealthController, MetricsController],
55+
controllers: [RunsController, RunInsightsController, RuntimeController, ObservabilityController, HealthController, MetricsController, AdminController, AuditController, WebhookController],
5156
providers: [
5257
{ provide: APP_GUARD, useClass: AuthGuard },
5358
{ provide: APP_GUARD, useClass: ThrottleByUserGuard },
@@ -75,7 +80,9 @@ import { StreamConsumerService } from './runs/stream-consumer.service';
7580
StreamConsumerService,
7681
RunExecutorService,
7782
RunRecoveryService,
78-
RunInsightsService
83+
RunInsightsService,
84+
WebhookRepository,
85+
WebhookService
7986
]
8087
})
8188
export class AppModule implements NestModule {

src/artifacts/inline-artifact-storage.provider.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Injectable } from '@nestjs/common';
2+
import { ArtifactRepository } from '../storage/artifact.repository';
23
import { ArtifactStorageProvider } from './artifact-storage.interface';
34

45
@Injectable()
56
export class InlineArtifactStorageProvider implements ArtifactStorageProvider {
67
readonly kind = 'inline';
78

9+
constructor(private readonly artifactRepository: ArtifactRepository) {}
10+
811
async store(params: {
912
runId: string;
1013
artifactId: string;
@@ -17,8 +20,17 @@ export class InlineArtifactStorageProvider implements ArtifactStorageProvider {
1720
return { uri: `inline://${params.runId}/${params.artifactId}` };
1821
}
1922

20-
async retrieve(_uri: string): Promise<{ data: Buffer; contentType?: string } | null> {
21-
// Inline artifacts are read directly from the DB, not through this provider.
22-
return null;
23+
async retrieve(uri: string): Promise<{ data: Buffer; contentType?: string } | null> {
24+
// Parse inline URI: inline://{runId}/{artifactId}
25+
const match = uri.match(/^inline:\/\/([^/]+)\/([^/]+)$/);
26+
if (!match) return null;
27+
28+
const artifact = await this.artifactRepository.findById(match[2]);
29+
if (!artifact?.inline) return null;
30+
31+
return {
32+
data: Buffer.from(JSON.stringify(artifact.inline), 'utf8'),
33+
contentType: 'application/json'
34+
};
2335
}
2436
}

src/audit/audit.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, Logger } from '@nestjs/common';
2+
import { and, desc, eq, gt, lt, sql, SQL } from 'drizzle-orm';
23
import { randomUUID } from 'node:crypto';
34
import { DatabaseService } from '../db/database.service';
45
import { auditLog } from '../db/schema';
@@ -37,4 +38,41 @@ export class AuditService {
3738
this.logger.error(`audit record failed: ${error instanceof Error ? error.message : String(error)}`);
3839
}
3940
}
41+
42+
async list(filters: {
43+
actor?: string;
44+
action?: string;
45+
resource?: string;
46+
resourceId?: string;
47+
createdAfter?: string;
48+
createdBefore?: string;
49+
limit?: number;
50+
offset?: number;
51+
}): Promise<{ data: (typeof auditLog.$inferSelect)[]; total: number }> {
52+
const conditions: SQL[] = [];
53+
if (filters.actor) conditions.push(eq(auditLog.actor, filters.actor));
54+
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
55+
if (filters.resource) conditions.push(eq(auditLog.resource, filters.resource));
56+
if (filters.resourceId) conditions.push(eq(auditLog.resourceId, filters.resourceId));
57+
if (filters.createdAfter) conditions.push(gt(auditLog.createdAt, filters.createdAfter));
58+
if (filters.createdBefore) conditions.push(lt(auditLog.createdAt, filters.createdBefore));
59+
60+
const where = conditions.length > 0 ? and(...conditions) : undefined;
61+
62+
const [data, countResult] = await Promise.all([
63+
this.database.db
64+
.select()
65+
.from(auditLog)
66+
.where(where)
67+
.orderBy(desc(auditLog.createdAt))
68+
.limit(filters.limit ?? 50)
69+
.offset(filters.offset ?? 0),
70+
this.database.db
71+
.select({ count: sql<number>`count(*)::int` })
72+
.from(auditLog)
73+
.where(where)
74+
]);
75+
76+
return { data, total: countResult[0]?.count ?? 0 };
77+
}
4078
}

src/contracts/runtime.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,48 @@ export interface RuntimeCallOptions {
165165
deadline?: Date;
166166
}
167167

168+
/** Request to open a unified bidirectional session stream */
169+
export interface RuntimeOpenSessionRequest {
170+
runId: string;
171+
execution: ExecutionRequest;
172+
}
173+
174+
/** Handle to an open bidirectional StreamSession */
175+
export interface RuntimeSessionHandle {
176+
/** Send an envelope through the open stream */
177+
send(envelope: RuntimeEnvelope): void;
178+
/** Async iterable of raw events from the stream */
179+
events: AsyncIterable<RawRuntimeEvent>;
180+
/** Close the write side (after all kickoff messages sent) */
181+
closeWrite(): void;
182+
/** Abort the stream immediately */
183+
abort(): void;
184+
/** The ack derived from the SessionStart echo (resolved after first response) */
185+
sessionAck: Promise<RuntimeStartSessionResult>;
186+
}
187+
188+
/** Stored runtime capabilities from Initialize response */
189+
export interface RuntimeCapabilities {
190+
sessions?: { stream?: boolean };
191+
cancellation?: { cancelSession?: boolean };
192+
progress?: { progress?: boolean };
193+
manifest?: { getManifest?: boolean };
194+
modeRegistry?: { listModes?: boolean; listChanged?: boolean };
195+
roots?: { listRoots?: boolean; listChanged?: boolean };
196+
}
197+
168198
export interface RuntimeProvider {
169199
readonly kind: string;
170200

171201
initialize(req: RuntimeInitializeRequest, opts?: RuntimeCallOptions): Promise<RuntimeInitializeResult>;
202+
203+
/** Open a unified bidirectional session — replaces startSession() + streamSession() */
204+
openSession(req: RuntimeOpenSessionRequest): RuntimeSessionHandle;
205+
206+
/** @deprecated Use openSession() for new session creation. Kept for backward compat. */
172207
startSession(req: RuntimeStartSessionRequest, opts?: RuntimeCallOptions): Promise<RuntimeStartSessionResult>;
173208
send(req: RuntimeSendRequest): Promise<RuntimeSendResult>;
209+
/** @deprecated Use openSession().events for streaming. Kept for reconnection fallback. */
174210
streamSession(req: RuntimeStreamSessionRequest): AsyncIterable<RawRuntimeEvent>;
175211
getSession(req: RuntimeGetSessionRequest): Promise<RuntimeSessionSnapshot>;
176212
cancelSession(req: RuntimeCancelSessionRequest): Promise<RuntimeCancelResult>;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Controller, Post, HttpCode } from '@nestjs/common';
2+
import { ApiOperation, ApiTags } from '@nestjs/swagger';
3+
import { RustRuntimeProvider } from '../runtime/rust-runtime.provider';
4+
5+
@ApiTags('admin')
6+
@Controller('admin')
7+
export class AdminController {
8+
constructor(private readonly rustRuntime: RustRuntimeProvider) {}
9+
10+
@Post('circuit-breaker/reset')
11+
@HttpCode(200)
12+
@ApiOperation({ summary: 'Manually reset the circuit breaker to CLOSED state.' })
13+
resetCircuitBreaker() {
14+
this.rustRuntime.resetCircuitBreaker();
15+
return { status: 'ok', state: 'CLOSED' };
16+
}
17+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
2+
import { ApiOperation, ApiTags } from '@nestjs/swagger';
3+
import { AuditService } from '../audit/audit.service';
4+
import { ListAuditQueryDto } from '../dto/list-audit-query.dto';
5+
6+
@ApiTags('audit')
7+
@Controller('audit')
8+
export class AuditController {
9+
constructor(private readonly auditService: AuditService) {}
10+
11+
@Get()
12+
@ApiOperation({ summary: 'List audit log entries with optional filtering.' })
13+
async listAuditLogs(
14+
@Query(new ValidationPipe({ transform: true, whitelist: true })) query: ListAuditQueryDto
15+
) {
16+
return this.auditService.list({
17+
actor: query.actor,
18+
action: query.action,
19+
resource: query.resource,
20+
resourceId: query.resourceId,
21+
createdAfter: query.createdAfter,
22+
createdBefore: query.createdBefore,
23+
limit: query.limit ?? 50,
24+
offset: query.offset ?? 0
25+
});
26+
}
27+
}

0 commit comments

Comments
 (0)