From 9302b4d9a1cdfbb7da54d53679f34398727b4971 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 17:14:14 +1200 Subject: [PATCH 1/6] Stabilize gRPC demo server reliability --- examples/demo-api/README.md | 17 ++ .../gRPC/chat-users-bidi-streaming.yml | 2 +- .../demo-api/gRPC/echo-metadata-defaults.yml | 2 +- examples/demo-api/gRPC/echo-unary.yml | 2 +- examples/demo-api/gRPC/folder.yml | 2 +- .../demo-api/gRPC/runtime-unary-lifecycle.yml | 2 +- examples/demo-api/gRPC/stream-users-error.yml | 2 +- examples/demo-api/gRPC/stream-users.yml | 2 +- .../gRPC/upload-users-client-streaming.yml | 2 +- examples/demo-api/grpc-server.js | 2 + examples/demo-api/server.js | 7 + src/copilot/tools/sendRequestTool.ts | 3 + src/services/grpcClient.ts | 40 +++- src/services/requestExecutionService.ts | 4 + test/grpcSupport.test.ts | 203 ++++++++++++++++++ 15 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 examples/demo-api/README.md diff --git a/examples/demo-api/README.md b/examples/demo-api/README.md new file mode 100644 index 0000000..bfca2e5 --- /dev/null +++ b/examples/demo-api/README.md @@ -0,0 +1,17 @@ +# Missio Demo API + +The demo collection is local-only and does not require internet access for protocol fixtures. + +Start the HTTP, GraphQL, WebSocket, runtime, auth, redirect, and binary upload fixture: + +```powershell +node examples/demo-api/server.js +``` + +Start the gRPC fixture in a second terminal before sending requests under `examples/demo-api/gRPC/`: + +```powershell +node examples/demo-api/grpc-server.js +``` + +The gRPC fixture binds `127.0.0.1:50051`, matching `LOCAL.grpcBaseUrl=localhost:50051` in `opencollection.yml`. If a gRPC demo request reports `UNAVAILABLE` or `ECONNREFUSED`, start or restart that fixture and retry with the LOCAL environment selected. diff --git a/examples/demo-api/gRPC/chat-users-bidi-streaming.yml b/examples/demo-api/gRPC/chat-users-bidi-streaming.yml index e1b3fa3..c2bffc1 100644 --- a/examples/demo-api/gRPC/chat-users-bidi-streaming.yml +++ b/examples/demo-api/gRPC/chat-users-bidi-streaming.yml @@ -25,4 +25,4 @@ grpc: "requestId": "{{grpcRequestId}}-chat-2" } } -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, then send this bidirectional-streaming gRPC request. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this bidirectional-streaming gRPC request to `LOCAL.grpcBaseUrl=localhost:50051`. diff --git a/examples/demo-api/gRPC/echo-metadata-defaults.yml b/examples/demo-api/gRPC/echo-metadata-defaults.yml index c213e31..ceabe04 100644 --- a/examples/demo-api/gRPC/echo-metadata-defaults.yml +++ b/examples/demo-api/gRPC/echo-metadata-defaults.yml @@ -17,4 +17,4 @@ grpc: "requestId": "{{grpcRequestId}}" } } -docs: Run `node examples/demo-api/grpc-server.js` to verify collection, folder, and request metadata merging. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this unary gRPC request to verify collection, folder, and request metadata merging at `localhost:50051`. diff --git a/examples/demo-api/gRPC/echo-unary.yml b/examples/demo-api/gRPC/echo-unary.yml index 37d41df..d8bd4a0 100644 --- a/examples/demo-api/gRPC/echo-unary.yml +++ b/examples/demo-api/gRPC/echo-unary.yml @@ -23,4 +23,4 @@ runtime: auth: type: bearer token: "{{grpcToken}}" -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, then send this unary gRPC request. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this unary gRPC request to `LOCAL.grpcBaseUrl=localhost:50051`. diff --git a/examples/demo-api/gRPC/folder.yml b/examples/demo-api/gRPC/folder.yml index a96e666..0bde27b 100644 --- a/examples/demo-api/gRPC/folder.yml +++ b/examples/demo-api/gRPC/folder.yml @@ -5,4 +5,4 @@ request: metadata: - name: x-demo-folder value: folder-{{grpcRequestId}} -docs: Run `node examples/demo-api/grpc-server.js` before sending these local gRPC requests. +docs: From the repository root, run `node examples/demo-api/grpc-server.js` before sending these local gRPC requests. The fixture binds `127.0.0.1:50051`, matching `LOCAL.grpcBaseUrl=localhost:50051`. diff --git a/examples/demo-api/gRPC/runtime-unary-lifecycle.yml b/examples/demo-api/gRPC/runtime-unary-lifecycle.yml index 0adc1c7..77f85ce 100644 --- a/examples/demo-api/gRPC/runtime-unary-lifecycle.yml +++ b/examples/demo-api/gRPC/runtime-unary-lifecycle.yml @@ -63,4 +63,4 @@ runtime: variable: scope: runtime name: grpcRuntimeRequestId -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, optionally edit the request Runtime tab, then send to see unary gRPC metadata/message mutation plus runtime tests, assertions, logs, and set-variable actions. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, optionally edit the request Runtime tab, then send to `LOCAL.grpcBaseUrl=localhost:50051` to see unary gRPC metadata/message mutation plus runtime tests, assertions, logs, and set-variable actions. diff --git a/examples/demo-api/gRPC/stream-users-error.yml b/examples/demo-api/gRPC/stream-users-error.yml index 9300b0c..052f474 100644 --- a/examples/demo-api/gRPC/stream-users-error.yml +++ b/examples/demo-api/gRPC/stream-users-error.yml @@ -14,4 +14,4 @@ grpc: "requestId": "{{grpcRequestId}}-error" } } -docs: Run `node examples/demo-api/grpc-server.js`; this server-streaming request emits one response and then a deterministic gRPC INTERNAL error for diagnostics testing. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this server-streaming request. It emits one response from `localhost:50051` and then a deterministic gRPC INTERNAL error for diagnostics testing. diff --git a/examples/demo-api/gRPC/stream-users.yml b/examples/demo-api/gRPC/stream-users.yml index 0cf1bb3..de75faa 100644 --- a/examples/demo-api/gRPC/stream-users.yml +++ b/examples/demo-api/gRPC/stream-users.yml @@ -14,4 +14,4 @@ grpc: "requestId": "{{grpcRequestId}}" } } -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, then send this server-streaming gRPC request. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this server-streaming gRPC request to `LOCAL.grpcBaseUrl=localhost:50051`. diff --git a/examples/demo-api/gRPC/upload-users-client-streaming.yml b/examples/demo-api/gRPC/upload-users-client-streaming.yml index efc30bc..cffdf94 100644 --- a/examples/demo-api/gRPC/upload-users-client-streaming.yml +++ b/examples/demo-api/gRPC/upload-users-client-streaming.yml @@ -32,4 +32,4 @@ runtime: auth: type: bearer token: "{{grpcToken}}" -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, then send this client-streaming gRPC request. +docs: Run `node examples/demo-api/grpc-server.js` from the repository root, select the LOCAL environment, then send this client-streaming gRPC request to `LOCAL.grpcBaseUrl=localhost:50051`. diff --git a/examples/demo-api/grpc-server.js b/examples/demo-api/grpc-server.js index b8a1651..570b7ed 100644 --- a/examples/demo-api/grpc-server.js +++ b/examples/demo-api/grpc-server.js @@ -105,6 +105,8 @@ server.bindAsync(ADDRESS, grpc.ServerCredentials.createInsecure(), (err, port) = } server.start(); console.log(`Missio gRPC Demo Server -> ${ADDRESS}`); + console.log('Start command: node examples/demo-api/grpc-server.js'); + console.log('OpenCollection LOCAL.grpcBaseUrl: localhost:50051'); console.log(`Proto: ${PROTO_PATH}`); console.log('Methods:'); console.log(' missio.demo.DemoService/EchoUnary'); diff --git a/examples/demo-api/server.js b/examples/demo-api/server.js index 7d0b48f..66a2343 100644 --- a/examples/demo-api/server.js +++ b/examples/demo-api/server.js @@ -6,6 +6,10 @@ * * Runs on http://localhost:3456 * + * gRPC demo requests use a separate local fixture: + * node examples/demo-api/grpc-server.js + * It binds 127.0.0.1:50051, matching LOCAL.grpcBaseUrl in opencollection.yml. + * * Routes: * GET /health – liveness check * POST /upload – accepts any binary body, returns upload info as JSON @@ -496,6 +500,9 @@ server.listen(PORT, '127.0.0.1', () => { console.log(` WS ws://localhost:${PORT}/ws/push (server push after connect)`); console.log(` WS ws://localhost:${PORT}/ws/close (deterministic server close)`); console.log(` WS ws://localhost:${PORT}/ws/reject (deterministic upgrade rejection)`); + console.log('\ngRPC demo requests:'); + console.log(' Start the separate gRPC fixture: node examples/demo-api/grpc-server.js'); + console.log(' It binds 127.0.0.1:50051, matching LOCAL.grpcBaseUrl=localhost:50051.'); console.log('\nFixture files for demo requests:'); console.log(` ${path.join(FIXTURES_DIR, 'sample.png')}`); console.log(` ${path.join(FIXTURES_DIR, 'sample.txt')}`); diff --git a/src/copilot/tools/sendRequestTool.ts b/src/copilot/tools/sendRequestTool.ts index b0a73d4..283201b 100644 --- a/src/copilot/tools/sendRequestTool.ts +++ b/src/copilot/tools/sendRequestTool.ts @@ -177,6 +177,9 @@ export class SendRequestTool extends ToolBase { if ((err as any)?.runtime) { result.runtime = (err as any).runtime; } + if (typeof (err as any)?.hint === 'string') { + result.hint = (err as any).hint; + } return JSON.stringify(result); } diff --git a/src/services/grpcClient.ts b/src/services/grpcClient.ts index d54a208..4bcad3f 100644 --- a/src/services/grpcClient.ts +++ b/src/services/grpcClient.ts @@ -76,6 +76,7 @@ interface GrpcStatusSummary { interface GrpcErrorSummary extends GrpcStatusSummary { message: string; + hint?: string; } export class GrpcStreamingUnsupportedError extends Error { @@ -266,7 +267,7 @@ export class GrpcClient implements vscode.Disposable { } if (err) { finish(); - reject(this._normalizeGrpcError(err, args.displayMethod)); + reject(this._normalizeGrpcError(err, args.displayMethod, args.target)); return; } @@ -349,6 +350,7 @@ export class GrpcClient implements vscode.Disposable { responseMetadata, finalStatus, error, + target: args.target, duration: Date.now() - startTime, timing: args.timing, }); @@ -378,7 +380,7 @@ export class GrpcClient implements vscode.Disposable { events.push({ type: 'status', status: this._statusSummary(status), elapsedMs: Date.now() - startTime }); }); call.on('error', err => { - events.push({ type: 'error', error: this._errorSummary(err), elapsedMs: Date.now() - startTime }); + events.push({ type: 'error', error: this._errorSummary(err, args.target), elapsedMs: Date.now() - startTime }); settle(err); }); call.on('end', () => { @@ -437,6 +439,7 @@ export class GrpcClient implements vscode.Disposable { responseMetadata, finalStatus, error, + target: args.target, duration: Date.now() - startTime, timing: args.timing, }); @@ -449,7 +452,7 @@ export class GrpcClient implements vscode.Disposable { { deadline: new Date(Date.now() + timeout) }, (err: grpc.ServiceError | null, response: unknown) => { if (err) { - events.push({ type: 'error', error: this._errorSummary(err), elapsedMs: Date.now() - startTime }); + events.push({ type: 'error', error: this._errorSummary(err, args.target), elapsedMs: Date.now() - startTime }); settle(err); return; } @@ -468,7 +471,7 @@ export class GrpcClient implements vscode.Disposable { events.push({ type: 'status', status: this._statusSummary(status), elapsedMs: Date.now() - startTime }); }); call.on('error', err => { - events.push({ type: 'error', error: this._errorSummary(err), elapsedMs: Date.now() - startTime }); + events.push({ type: 'error', error: this._errorSummary(err, args.target), elapsedMs: Date.now() - startTime }); settle(err); }); @@ -538,6 +541,7 @@ export class GrpcClient implements vscode.Disposable { responseMetadata, finalStatus, error, + target: args.target, duration: Date.now() - startTime, timing: args.timing, }); @@ -565,7 +569,7 @@ export class GrpcClient implements vscode.Disposable { events.push({ type: 'status', status: this._statusSummary(status), elapsedMs: Date.now() - startTime }); }); call.on('error', err => { - events.push({ type: 'error', error: this._errorSummary(err), elapsedMs: Date.now() - startTime }); + events.push({ type: 'error', error: this._errorSummary(err, args.target), elapsedMs: Date.now() - startTime }); settle(err); }); call.on('end', () => { @@ -599,10 +603,11 @@ export class GrpcClient implements vscode.Disposable { responseMetadata: grpc.Metadata; finalStatus?: grpc.StatusObject; error?: grpc.ServiceError | Error; + target: ResolvedGrpcTarget; duration: number; timing: { label: string; start: number; end: number }[]; }): HttpResponse { - const error = args.error ? this._errorSummary(args.error) : undefined; + const error = args.error ? this._errorSummary(args.error, args.target) : undefined; const status = args.finalStatus ? this._statusSummary(args.finalStatus) : error @@ -660,31 +665,46 @@ export class GrpcClient implements vscode.Disposable { return vscode.workspace.getConfiguration('missio').get('timeout', 30000); } - private _normalizeGrpcError(err: grpc.ServiceError, method: string): Error { - const summary = this._errorSummary(err); + private _normalizeGrpcError(err: grpc.ServiceError, method: string, target: ResolvedGrpcTarget): Error { + const summary = this._errorSummary(err, target); const message = `gRPC ${method} failed with ${summary.name}: ${summary.details || summary.message}`; const normalized = new Error(message); (normalized as any).code = `GRPC_${summary.name}`; (normalized as any).grpcStatus = summary.code; (normalized as any).grpcDetails = summary.details; + if (summary.hint) { + (normalized as any).hint = summary.hint; + } return normalized; } - private _errorSummary(err: grpc.ServiceError | Error): GrpcErrorSummary { + private _errorSummary(err: grpc.ServiceError | Error, target?: ResolvedGrpcTarget): GrpcErrorSummary { const grpcCode = typeof (err as grpc.ServiceError).code === 'number' ? (err as grpc.ServiceError).code : grpc.status.UNKNOWN; const details = typeof (err as grpc.ServiceError).details === 'string' && (err as grpc.ServiceError).details ? (err as grpc.ServiceError).details : err.message; + const hint = this._localDemoFixtureHint(target, grpcCode, details || err.message); return { code: grpcCode, name: this._statusName(grpcCode), - details, + details: hint ? `${details} ${hint}` : details, message: err.message, + hint, }; } + private _localDemoFixtureHint(target: ResolvedGrpcTarget | undefined, grpcCode: number, details: string): string | undefined { + if (grpcCode !== grpc.status.UNAVAILABLE) return undefined; + const normalizedTarget = target?.target.trim().toLowerCase(); + if (!normalizedTarget || !['localhost:50051', '127.0.0.1:50051', '[::1]:50051', '::1:50051'].includes(normalizedTarget)) { + return undefined; + } + if (!/connect|connection|unavailable|refused|failed/i.test(details)) return undefined; + return 'Start the local Missio gRPC demo fixture with `node examples/demo-api/grpc-server.js` from the repository root. It binds 127.0.0.1:50051.'; + } + private _statusSummary(status: grpc.StatusObject): GrpcStatusSummary { return { code: status.code, diff --git a/src/services/requestExecutionService.ts b/src/services/requestExecutionService.ts index a222a1a..5adf2a2 100644 --- a/src/services/requestExecutionService.ts +++ b/src/services/requestExecutionService.ts @@ -436,6 +436,7 @@ function buildProtocolErrorResponse(protocol: 'websocket' | 'grpc', error: unkno code, grpcStatus, grpcDetails: typeof (err as any).grpcDetails === 'string' ? (err as any).grpcDetails : undefined, + hint: typeof (err as any).hint === 'string' ? (err as any).hint : undefined, stack: typeof err.stack === 'string' ? err.stack : undefined, }; const body = JSON.stringify({ protocol, error: details }, null, 2); @@ -449,6 +450,9 @@ function buildProtocolErrorResponse(protocol: 'websocket' | 'grpc', error: unkno if (grpcStatus !== undefined) { headers['x-missio-grpc-status'] = String(grpcStatus); } + if (details.hint) { + headers['x-missio-error-hint'] = details.hint; + } return { status: 0, diff --git a/test/grpcSupport.test.ts b/test/grpcSupport.test.ts index 415eba9..7c78f9b 100644 --- a/test/grpcSupport.test.ts +++ b/test/grpcSupport.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import Ajv from 'ajv'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import * as grpc from '@grpc/grpc-js'; @@ -91,6 +92,92 @@ function makeCollection(): MissioCollection { }; } +function makeDemoCollection(): MissioCollection { + const filePath = path.join(demoRoot, 'opencollection.yml'); + return { + id: filePath, + filePath, + rootDir: demoRoot, + data: parseYaml(fs.readFileSync(filePath, 'utf-8')) as any, + }; +} + +function readDemoGrpcRequest(fileName: string) { + return parseYaml(fs.readFileSync(path.join(demoRoot, 'gRPC', fileName), 'utf-8')); +} + +function readDemoGrpcFolderDefaults(): RequestDefaults | undefined { + return parseYaml(fs.readFileSync(path.join(demoRoot, 'gRPC', 'folder.yml'), 'utf-8')).request; +} + +function makeDemoGrpcExecution(environmentService: EnvironmentService): RequestExecutionService { + return new RequestExecutionService( + { send: vi.fn(), buildResolvedRequest: vi.fn(), cancelAll: vi.fn() } as any, + undefined, + new GrpcClient(environmentService), + new RuntimeExecutionService((runtimeCollection, folderDefaults, environmentName) => + environmentService.resolveVariables(runtimeCollection, folderDefaults, environmentName), + ), + ); +} + +function startDemoGrpcServerProcess(): Promise { + const child = spawn(process.execPath, [path.join(demoRoot, 'grpc-server.js')], { + cwd: path.resolve(__dirname, '..'), + stdio: 'pipe', + windowsHide: true, + }); + + return new Promise((resolve, reject) => { + let output = ''; + let settled = false; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + child.off('error', onError); + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(child); + }; + const onData = (chunk: Buffer) => { + output += chunk.toString('utf-8'); + if (/Listening on 127\.0\.0\.1:50051/i.test(output)) { + finish(); + } + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + finish(new Error(`gRPC demo server exited before readiness (code=${code}, signal=${signal}). Output:\n${output}`)); + }; + const onError = (error: Error) => finish(error); + const timer = setTimeout(() => { + finish(new Error(`Timed out waiting for gRPC demo server readiness. Output:\n${output}`)); + }, 10_000); + + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + child.once('error', onError); + }); +} + +async function stopDemoGrpcServerProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) return; + child.kill(); + await new Promise((resolve) => { + const timer = setTimeout(resolve, 2_000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + function makeUnaryRequest(url: string) { return { info: { name: 'Echo unary', type: 'grpc' as const }, @@ -752,3 +839,119 @@ describe('gRPC integration surfaces', () => { expect(response.runtime?.actions[0]).toMatchObject({ target: 'runtime.grpcRuntimeRequestId' }); }); }); + +describe('gRPC demo server reliability', () => { + it('reports the local fixture start command when packaged demo requests target closed localhost:50051', async () => { + const environmentService = makeEnvironmentService(); + const collection = makeDemoCollection(); + const request = readDemoGrpcRequest('echo-unary.yml'); + const execution = makeDemoGrpcExecution(environmentService); + + await expect(execution.send( + request as any, + collection, + readDemoGrpcFolderDefaults(), + undefined, + undefined, + 'LOCAL', + )).rejects.toThrow(/node examples\/demo-api\/grpc-server\.js/); + }); + + it('starts the documented fixture and smokes every packaged gRPC demo request through Missio execution', async () => { + const child = await startDemoGrpcServerProcess(); + try { + const environmentService = makeEnvironmentService(); + const collection = makeDemoCollection(); + await environmentService.setActiveEnvironment(collection.id, 'LOCAL'); + const execution = makeDemoGrpcExecution(environmentService); + const folderDefaults = readDemoGrpcFolderDefaults(); + const send = (fileName: string) => execution.send( + readDemoGrpcRequest(fileName) as any, + collection, + folderDefaults, + undefined, + undefined, + 'LOCAL', + undefined, + { requestId: path.join(demoRoot, 'gRPC', fileName) }, + ); + + const unary = JSON.parse((await send('echo-unary.yml')).body); + expect(unary).toMatchObject({ + message: 'Hello Ada', + name: 'Ada', + userId: 42, + requestId: 'demo-trace-001', + authorization: 'Bearer demo-token', + defaultMetadata: 'request-override-demo-trace-001', + folderMetadata: 'folder-demo-trace-001', + requestMetadata: 'request-demo-trace-001', + }); + + const metadata = JSON.parse((await send('echo-metadata-defaults.yml')).body); + expect(metadata).toMatchObject({ + message: 'Hello Metadata Ada', + defaultMetadata: 'collection-demo-trace-001', + folderMetadata: 'folder-demo-trace-001', + requestMetadata: 'metadata-defaults-demo-trace-001', + }); + + const serverStream = JSON.parse((await send('stream-users.yml')).body); + expect(serverStream).toMatchObject({ + methodType: 'server-streaming', + sentMessageCount: 1, + receivedMessageCount: 2, + }); + expect(serverStream.receivedMessages.map((entry: any) => entry.message.name)).toEqual(['Ada', 'Grace']); + + const clientStream = JSON.parse((await send('upload-users-client-streaming.yml')).body); + expect(clientStream).toMatchObject({ + methodType: 'client-streaming', + sentMessageCount: 2, + receivedMessageCount: 1, + }); + expect(clientStream.receivedMessages[0].message).toMatchObject({ + count: 2, + names: 'Ada,Grace', + requestIds: 'demo-trace-001-1,demo-trace-001-2', + authorization: 'Bearer demo-token', + defaultMetadata: 'collection-demo-trace-001', + }); + + const bidiStream = JSON.parse((await send('chat-users-bidi-streaming.yml')).body); + expect(bidiStream).toMatchObject({ + methodType: 'bidi-streaming', + sentMessageCount: 2, + receivedMessageCount: 2, + }); + expect(bidiStream.receivedMessages.map((entry: any) => entry.message.name)).toEqual(['ack:Ada', 'ack:Grace']); + + const errorResponse = await send('stream-users-error.yml'); + const errorStream = JSON.parse(errorResponse.body); + expect(errorResponse.status).toBe(0); + expect(errorStream.receivedMessages[0].message).toMatchObject({ + id: 42, + name: 'Ada', + requestId: 'demo-trace-001-error', + }); + expect(errorStream.error).toMatchObject({ + name: 'INTERNAL', + details: 'Demo stream failure after partial data', + }); + + const runtimeResponse = await send('runtime-unary-lifecycle.yml'); + const runtimeBody = JSON.parse(runtimeResponse.body); + expect(runtimeBody).toMatchObject({ + name: 'Ada Runtime', + userId: 77, + requestMetadata: 'script-demo-trace-001', + requestId: 'runtime-demo-trace-001', + }); + expect(runtimeResponse.runtime?.success).toBe(true); + expect(runtimeResponse.runtime?.summary).toEqual({ passed: 4, failed: 0, skipped: 0 }); + expect(runtimeResponse.runtime?.actions[0]).toMatchObject({ target: 'runtime.grpcRuntimeRequestId' }); + } finally { + await stopDemoGrpcServerProcess(child); + } + }, 20_000); +}); From 1de1cf9c803b26d0671ddb8b404850ece77e3ac5 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 17:17:00 +1200 Subject: [PATCH 2/6] Record OC-160 gRPC demo reliability completion --- .../AGENT_PROGRESS.md | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 7017506..a1b857c 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -20,7 +20,7 @@ Status values: `Unclaimed`, `Claimed`, `In Progress`, `Review Ready`, `Blocked`, | Role | Owner | Scope | Since | Current Focus | | --- | --- | --- | --- | --- | -| Supervisor | Codex | Sanity-check completed tracks, run build/test/package/install verification, preserve GitButler branch hygiene, and append progress reports. | 2026-06-14 22:31 NZT | OC-160 queued as a focused gRPC demo server startup and live verification reliability follow-up. | +| Supervisor | Codex | Sanity-check completed tracks, run build/test/package/install verification, preserve GitButler branch hygiene, and append progress reports. | 2026-06-14 22:31 NZT | OC-160 complete after gRPC demo server startup, diagnostics, live smoke, package, and install verification. | ## Test Coverage Rules @@ -73,7 +73,7 @@ Use full branch names for stacking existing branches with `but move