diff --git a/.gitignore b/.gitignore index 8c0c9d88..c6027624 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ report/ test-metadata-manual.sh test-isolated-fix.js lib/agent-cli-provider/ +lib/cluster/ diff --git a/.prettierignore b/.prettierignore index b28951d8..7c9109f5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,6 @@ coverage .tsbuildinfo .eslintcache lib/agent-cli-provider +lib/cluster +src/cluster/generated protocol/openengine-cluster/v1 diff --git a/AGENTS.md b/AGENTS.md index f6b48402..e27ea74b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,8 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero | Connection core/admission | `crates/openengine-cluster-server/src/connection.rs`, `connection/` | | NDJSON response pump | `crates/openengine-cluster-client/src/ndjson_pump.rs` | | Cluster typed transports | `crates/openengine-cluster-client/` | +| TypeScript cluster client | `src/cluster/` | +| TypeScript protocol emitter | `scripts/generate-cluster-types.js` | | Cluster fixtures/artifacts | `crates/openengine-cluster-testkit/` | | Portable backend conformance | `crates/openengine-cluster-testkit/src/conformance.rs` | | Scripted admission fixtures | `crates/openengine-cluster-testkit/src/admission.rs` | @@ -137,6 +139,11 @@ are excluded from Prettier; never format them independently. Native release metadata and npm installer code stay outside the Rust-only `zeroshot-rust/` package. `distribution/zeroshot-rust-targets.json` is the authoritative release target list; the workflow matrix and checksum coverage must match it exactly. +The published Node binding is the standalone `@the-open-engine/zeroshot/cluster` subpath. +`Connection` exclusively owns request IDs and transport teardown; watch reconnect consumes an old +stream exactly once on an application-supplied fresh connection. Keep `src/cluster/` isolated from +product internals, and regenerate its wire types from the authoritative protocol artifacts with +`npm run protocol:generate`. The protocol and server crates own wire contracts, backend traits, the dispatcher, and transports. Portable external conformance is the immutable public catalog in the testkit and covers only backend-neutral behavior observable through public dispatcher and typed subscription surfaces. diff --git a/eslint.config.mjs b/eslint.config.mjs index e20bf759..2aee8605 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -329,6 +329,7 @@ export default [ 'hooks/**', 'lib/tui-backend/**', 'lib/agent-cli-provider/**', + 'lib/cluster/**', ], }, prettierConfig, diff --git a/package-lock.json b/package-lock.json index c37ff6b1..f086bf67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "optionalDependencies": { + "ws": "^8.18.3" } }, "node_modules/@actions/core": { @@ -13368,6 +13371,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 78b2b85d..8d934c63 100644 --- a/package.json +++ b/package.json @@ -40,21 +40,26 @@ "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'", "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js", "start": "node cli/index.js", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && npm run typecheck:cluster", "typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json", + "typecheck:cluster": "tsc --project tsconfig.cluster.json", "lint:agent-cli-provider": "eslint \"src/agent-cli-provider/**/*.ts\" \"tests/agent-cli-provider/**/*.ts\"", "build:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.build.json", + "build:cluster": "npm run protocol:generate && tsc --project tsconfig.cluster.cjs.json && tsc --project tsconfig.cluster.esm.json && node scripts/build-cluster.js", "test:agent-cli-provider": "node --test tests/agent-cli-provider/*.test.js", "test:providers:live": "npm run build:agent-cli-provider && node scripts/live-provider-smoke.js", "check:agent-cli-provider": "npm run check:agent-cli-provider:ci", "check:agent-cli-provider:ci": "npm run typecheck:agent-cli-provider && npm run lint:agent-cli-provider && npm run build:agent-cli-provider && npm run test:agent-cli-provider", + "test:cluster-client": "npm run build:cluster && node --test tests/cluster/client.test.js tests/cluster/parity.test.js tests/cluster/architecture.test.js tests/cluster/verifier-regressions.test.js", + "test:cluster-package": "npm run build:cluster && node --test tests/cluster/package.test.js", "dev:link": "npm link", "lint": "eslint .", "lint:fix": "eslint . --fix", "validate:templates": "node scripts/validate-templates.js", "format": "prettier --write .", "format:check": "prettier --check .", - "protocol:check": "cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --check", + "protocol:generate": "node scripts/generate-cluster-types.js", + "protocol:check": "cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --check && node scripts/generate-cluster-types.js --check", "rust:distribution:check": "node scripts/rust-distribution.js check-repository", "rust:check": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace", "deadcode": "ts-prune --skip node_modules", @@ -68,6 +73,7 @@ "release:preflight": "node scripts/release-preflight.js", "release:recover": "node scripts/release-recovery.js", "release:assert-published": "node scripts/assert-release-published.js", + "prepack": "npm run build:cluster", "prepublishOnly": "npm run lint && npm run typecheck && npm run check:agent-cli-provider:ci", "prepare": "npm run build:agent-cli-provider && husky" }, @@ -115,6 +121,17 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, + "exports": { + ".": "./src/orchestrator.js", + "./cluster": { + "types": "./lib/cluster/index.d.ts", + "import": "./lib/cluster/index.mjs", + "require": "./lib/cluster/index.cjs", + "default": "./lib/cluster/index.cjs" + }, + "./package.json": "./package.json", + "./*": "./*" + }, "files": [ "src/", "lib/", @@ -142,6 +159,9 @@ "pidusage": "^4.0.1", "proper-lockfile": "^4.1.2" }, + "optionalDependencies": { + "ws": "^8.18.3" + }, "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", diff --git a/scripts/build-cluster.js b/scripts/build-cluster.js new file mode 100644 index 00000000..f892e677 --- /dev/null +++ b/scripts/build-cluster.js @@ -0,0 +1,43 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const buildRoot = path.join(root, '.cluster-build'); +const outputRoot = path.join(root, 'lib/cluster'); + +function filesBelow(directory) { + const entries = fs.readdirSync(directory, { withFileTypes: true }); + return entries.flatMap((entry) => { + const absolute = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(absolute) : [absolute]; + }); +} + +function copyBuild(sourceRoot, mode) { + for (const source of filesBelow(sourceRoot)) { + const relative = path.relative(sourceRoot, source); + const extension = path.extname(relative); + if (extension === '.js') { + const targetExtension = mode === 'cjs' ? '.cjs' : '.mjs'; + const target = path.join(outputRoot, relative.slice(0, -3) + targetExtension); + let content = fs.readFileSync(source, 'utf8'); + content = content.replace( + /(["'])(\.\.?\/[^"']+)\.js\1/g, + (_match, quote, specifier) => `${quote}${specifier}${targetExtension}${quote}` + ); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } else if (mode === 'cjs' && extension === '.ts' && relative.endsWith('.d.ts')) { + const target = path.join(outputRoot, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + } + } +} + +fs.rmSync(outputRoot, { recursive: true, force: true }); +copyBuild(path.join(buildRoot, 'cjs'), 'cjs'); +copyBuild(path.join(buildRoot, 'esm'), 'esm'); +fs.rmSync(buildRoot, { recursive: true, force: true }); diff --git a/scripts/generate-cluster-types.js b/scripts/generate-cluster-types.js new file mode 100644 index 00000000..aaf7b94b --- /dev/null +++ b/scripts/generate-cluster-types.js @@ -0,0 +1,203 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const OPENRPC_PATH = path.join(ROOT, 'protocol/openengine-cluster/v1/openrpc.json'); +const SCHEMA_PATH = path.join(ROOT, 'protocol/openengine-cluster/v1/schema.json'); +const PROTOCOL_RS = path.join(ROOT, 'crates/openengine-cluster-protocol/src/lib.rs'); +const WATCH_RS = path.join(ROOT, 'crates/openengine-cluster-protocol/src/watch.rs'); +const CLIENT_RS = path.join(ROOT, 'crates/openengine-cluster-client/src/lib.rs'); +const OUTPUT_PATH = path.join(ROOT, 'src/cluster/generated/protocol.ts'); +const SCHEMA_OUTPUT_PATH = path.join(ROOT, 'src/cluster/generated/protocol-schema.ts'); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function rustString(source, name) { + const match = source.match(new RegExp(`pub const ${name}: &str = "([^"]+)";`)); + if (!match) throw new Error(`missing Rust string constant ${name}`); + return match[1]; +} + +function rustNumber(source, name) { + const match = source.match(new RegExp(`(?:pub )?const ${name}: [^=]+ = (-?[0-9_]+);`)); + if (!match) throw new Error(`missing Rust numeric constant ${name}`); + return Number(match[1].replaceAll('_', '')); +} + +function refName(ref) { + const prefix = '#/$defs/'; + if (!ref.startsWith(prefix)) throw new Error(`unsupported non-local schema reference ${ref}`); + return ref.slice(prefix.length); +} + +function literal(value) { + return JSON.stringify(value); +} + +function renderCombination(schema) { + for (const [keyword, operator] of [ + ['oneOf', ' | '], + ['anyOf', ' | '], + ['allOf', ' & '], + ]) { + const branches = schema[keyword]; + if (!Array.isArray(branches)) continue; + const combination = branches.map(schemaType).map(parenthesize).join(operator); + const hasBase = + schema.type !== undefined || + schema.properties !== undefined || + schema.additionalProperties !== undefined; + if (!hasBase) return combination; + const base = { ...schema }; + delete base[keyword]; + return `(${schemaType(base)}) & (${combination})`; + } + return undefined; +} + +function renderObject(schema) { + if (schema.properties) { + const required = new Set(schema.required ?? []); + const fields = Object.entries(schema.properties).map( + ([name, value]) => + `readonly ${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${schemaType(value)};` + ); + if (schema.additionalProperties && schema.additionalProperties !== false) { + fields.push(`readonly [key: string]: ${schemaType(schema.additionalProperties)};`); + } + return `{ ${fields.join(' ')} }`; + } + if (Array.isArray(schema.required) && schema.required.length > 0) { + const fields = schema.required.map((name) => `readonly ${JSON.stringify(name)}: unknown;`); + return `{ ${fields.join(' ')} }`; + } + if (schema.additionalProperties && schema.additionalProperties !== false) { + return `{ readonly [key: string]: ${schemaType(schema.additionalProperties)} }`; + } + return schema.type === 'object' ? 'Record' : 'unknown'; +} + +function schemaType(schema) { + if (schema === true) return 'unknown'; + if (schema === false) return 'never'; + if (!schema || typeof schema !== 'object') throw new Error('invalid JSON schema'); + if ('$ref' in schema) return refName(schema.$ref); + if ('const' in schema) return literal(schema.const); + if (Array.isArray(schema.enum)) return schema.enum.map(literal).join(' | ') || 'never'; + const combination = renderCombination(schema); + if (combination !== undefined) return combination; + if (Array.isArray(schema.type)) { + return schema.type.map((type) => schemaType({ ...schema, type })).join(' | '); + } + switch (schema.type) { + case 'null': + return 'null'; + case 'boolean': + return 'boolean'; + case 'integer': + case 'number': + return 'number'; + case 'string': + return 'string'; + case 'array': + return `ReadonlyArray<${schemaType(schema.items ?? true)}>`; + case 'object': + case undefined: + return renderObject(schema); + default: + throw new Error(`unsupported JSON schema type ${String(schema.type)}`); + } +} + +function parenthesize(value) { + return value.includes(' | ') || value.includes(' & ') ? `(${value})` : value; +} + +function generate() { + const openrpc = readJson(OPENRPC_PATH); + const schema = readJson(SCHEMA_PATH); + const protocolRust = fs.readFileSync(PROTOCOL_RS, 'utf8'); + const watchRust = fs.readFileSync(WATCH_RS, 'utf8'); + const clientRust = fs.readFileSync(CLIENT_RS, 'utf8'); + const methods = openrpc.methods.map((method) => method.name); + const subscriptionMethods = new Set(['watch', 'logs', 'agent/attach']); + const unaryMethods = methods.filter((method) => !subscriptionMethods.has(method)); + const resultDefinitions = Object.fromEntries( + openrpc.methods.map((method) => [method.name, method.result.schema.$ref.split('/').at(-1)]) + ); + const aliases = Object.entries(schema.$defs) + .map(([name, definition]) => `export type ${name} = ${schemaType(definition)};`) + .join('\n'); + + return ( + `// Generated by scripts/generate-cluster-types.js from the checked-in Cluster Protocol v1 artifacts.\n// Do not edit this file by hand. Run npm run protocol:generate.\n\n` + + `export const JSON_RPC_VERSION = ${literal(rustString(protocolRust, 'JSON_RPC_VERSION'))} as const;\n` + + `export const PROTOCOL_VERSION = ${literal(rustString(protocolRust, 'PROTOCOL_VERSION'))} as const;\n` + + `export const SUBSCRIPTION_QUEUE_CAPACITY = ${rustNumber(watchRust, 'DEFAULT_SUBSCRIPTION_QUEUE_CAPACITY')} as const;\n` + + `export const MAX_FRAME_BYTES = ${rustNumber(clientRust, 'MAX_FRAME_BYTES')} as const;\n` + + `export const CLUSTER_METHODS = ${JSON.stringify(methods)} as const;\n` + + `export type ClusterMethod = (typeof CLUSTER_METHODS)[number];\n\n` + + `export const UNARY_METHODS = ${JSON.stringify(unaryMethods)} as const;\n` + + `export type UnaryClusterMethod = (typeof UNARY_METHODS)[number];\n` + + `export const SUBSCRIPTION_METHODS = ${JSON.stringify([...subscriptionMethods])} as const;\n` + + `export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];\n` + + `export const METHOD_RESULT_DEFINITIONS = ${JSON.stringify(resultDefinitions)} as const;\n\n` + + `export const JSON_RPC_ERROR_CODES = {\n` + + ` PARSE_ERROR: ${rustNumber(protocolRust, 'PARSE_ERROR')},\n` + + ` INVALID_REQUEST: ${rustNumber(protocolRust, 'INVALID_REQUEST')},\n` + + ` METHOD_NOT_FOUND: ${rustNumber(protocolRust, 'METHOD_NOT_FOUND')},\n` + + ` INVALID_PARAMS: ${rustNumber(protocolRust, 'INVALID_PARAMS')},\n` + + ` INTERNAL_ERROR: ${rustNumber(protocolRust, 'INTERNAL_ERROR')},\n` + + ` APPLICATION_ERROR: ${rustNumber(protocolRust, 'APPLICATION_ERROR')},\n` + + `} as const;\n\n` + + `export const DOMAIN_ERROR_CODES = ${JSON.stringify([ + rustString(watchRust, 'NOT_FOUND'), + rustString(watchRust, 'GONE'), + rustString(watchRust, 'SLOW_CONSUMER'), + rustString(protocolRust, 'UNSUPPORTED_PROTOCOL_VERSION'), + rustString(protocolRust, 'INTERNAL_ERROR_CODE'), + ])} as const;\n\n` + + aliases + + '\n\n' + + `export interface ClusterMethodParams {\n` + + ` readonly initialize: InitializeParams; readonly plan: PlanParams; readonly apply: ApplyParams;\n` + + ` readonly update: UpdateParams; readonly stop: StopParams; readonly retry: RetryParams;\n` + + ` readonly resubmit: ResubmitParams; readonly delete: DeleteParams; readonly get: GetParams;\n` + + ` readonly watch: WatchParams; readonly logs: LogsParams; readonly 'agent/attach': AgentAttachParams;\n` + + `}\n\n` + + `export interface ClusterMethodResults {\n` + + ` readonly initialize: InitializeResult; readonly plan: PlanResult; readonly apply: ApplyResult;\n` + + ` readonly update: UpdateResult; readonly stop: StopResult; readonly retry: RetryResult;\n` + + ` readonly resubmit: ResubmitResult; readonly delete: DeleteResult; readonly get: GetResult;\n` + + ` readonly watch: WatchResult; readonly logs: LogsResult; readonly 'agent/attach': AgentAttachResult;\n` + + `}\n` + ); +} + +const expected = generate(); +const schemaExpected = `// Generated from protocol/openengine-cluster/v1/schema.json. Do not edit.\nexport const CLUSTER_PROTOCOL_SCHEMA: unknown = ${JSON.stringify(readJson(SCHEMA_PATH))};\n`; +const check = process.argv.includes('--check'); +const outputs = [ + [OUTPUT_PATH, expected], + [SCHEMA_OUTPUT_PATH, schemaExpected], +]; +if (check) { + for (const [outputPath, outputExpected] of outputs) { + const actual = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : ''; + if (actual !== outputExpected) { + console.error( + `Generated cluster protocol output is out of date: ${path.relative(ROOT, outputPath)}` + ); + process.exitCode = 1; + } + } +} else { + for (const [outputPath, outputExpected] of outputs) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, outputExpected); + } +} diff --git a/src/cluster/client.ts b/src/cluster/client.ts new file mode 100644 index 00000000..e9a69b3b --- /dev/null +++ b/src/cluster/client.ts @@ -0,0 +1,199 @@ +import { PROTOCOL_VERSION } from './generated/protocol.js'; +import type { + AgentAttachParams, AgentAttachResult, ApplyParams, ApplyResult, DeleteParams, DeleteResult, + GetParams, GetResult, InitializeParams, InitializeResult, LogsParams, LogsResult, + PlanParams, PlanResult, ResubmitParams, ResubmitResult, RetryParams, RetryResult, + StopParams, StopResult, UpdateParams, UpdateResult, WatchParams, WatchResult, +} from './generated/protocol.js'; +import { Connection } from './connection.js'; +import type { CallOptions } from './connection.js'; +import { addSocketListener } from './socket.js'; +import type { WebSocketLike } from './socket.js'; +import { ClusterConfigError, ClusterProtocolError, ClusterTransportError } from './errors.js'; +import { + AgentAttachSubscriptionStream, + LogsSubscriptionStream, + WatchSubscriptionStream, +} from './subscriptions.js'; + +export interface ConnectOptions { + readonly protocols?: string | readonly string[]; + readonly webSocketFactory?: (url: string, protocols?: string | readonly string[]) => WebSocketLike | Promise; + readonly signal?: AbortSignal; + readonly initialize?: InitializeParams; +} +export interface WatchSubscription { readonly result: WatchResult; readonly stream: WatchSubscriptionStream; } +export interface LogsSubscription { readonly result: LogsResult; readonly stream: LogsSubscriptionStream; } +export interface AgentAttachSubscription { readonly result: AgentAttachResult; readonly stream: AgentAttachSubscriptionStream; } +export interface CoherentWatchSubscription extends WatchSubscription { readonly snapshot: GetResult; } + +export class ClusterClient { + constructor(readonly connection: Connection) {} + async initialize( + params: InitializeParams = { protocolVersion: PROTOCOL_VERSION }, + options?: CallOptions, + ): Promise { + const result = await this.connection.call('initialize', params, options); + if (result.protocolVersion !== params.protocolVersion || result.protocolVersion !== PROTOCOL_VERSION) { + throw new ClusterProtocolError( + `protocol version mismatch: requested ${params.protocolVersion}, received ${result.protocolVersion}`, + 'UNSUPPORTED_PROTOCOL_VERSION', + ); + } + return result; + } + plan(params: PlanParams, options?: CallOptions): Promise { + return this.connection.call('plan', params, options); + } + apply(params: ApplyParams, options?: CallOptions): Promise { + return this.connection.call('apply', params, options); + } + update(params: UpdateParams, options?: CallOptions): Promise { + return this.connection.call('update', params, options); + } + stop(params: StopParams, options?: CallOptions): Promise { + return this.connection.call('stop', params, options); + } + retry(params: RetryParams, options?: CallOptions): Promise { + return this.connection.call('retry', params, options); + } + resubmit(params: ResubmitParams, options?: CallOptions): Promise { + return this.connection.call('resubmit', params, options); + } + delete(params: DeleteParams, options?: CallOptions): Promise { + return this.connection.call('delete', params, options); + } + get(params: GetParams = {}, options?: CallOptions): Promise { + return this.connection.call('get', params, options); + } + async watch( + params: WatchParams = {}, + options?: CallOptions, + ): Promise { + const established = await this.connection.openSubscription('watch', params, options); + return { + result: established.result, + stream: new WatchSubscriptionStream({ + connection: this.connection, + registration: established.registration, + result: established.result, + params, + }), + }; + } + async watchColdStart( + params: Omit = {}, + options?: CallOptions, + ): Promise { + const snapshot = await this.get({}, options); + const watch = await this.watch({ + ...params, + ...(snapshot.atCursor === undefined ? {} : { fromCursor: snapshot.atCursor }), + }, options); + return { snapshot, ...watch }; + } + async logs(params: LogsParams = {}, options?: CallOptions): Promise { + const established = await this.connection.openSubscription('logs', params, options); + return { + result: established.result, + stream: new LogsSubscriptionStream(this.connection, established.registration), + }; + } + async agentAttach( + params: AgentAttachParams, + options?: CallOptions, + ): Promise { + const established = await this.connection.openSubscription('agent/attach', params, options); + return { + result: established.result, + stream: new AgentAttachSubscriptionStream(this.connection, established.registration), + }; + } +} + +async function defaultWebSocketFactory( + url: string, + protocols?: string | readonly string[], +): Promise { + const globalWebSocket = (globalThis as { + readonly WebSocket?: new ( + url: string, + protocols?: string | readonly string[], + ) => WebSocketLike; + }).WebSocket; + if (globalWebSocket) return new globalWebSocket(url, protocols); + try { + // Dynamic loading keeps the optional Node runtime off the browser/global-WebSocket path. + const imported: unknown = await import('ws'); + const candidate = imported !== null && typeof imported === 'object' && 'default' in imported + ? imported.default + : imported; + if (typeof candidate !== 'function') { + throw new TypeError("The installed 'ws' module does not export a WebSocket constructor"); + } + const Constructor = candidate as new ( + url: string, + protocols?: string | readonly string[], + ) => WebSocketLike; + return new Constructor(url, protocols); + } catch (cause) { + throw new ClusterConfigError( + "No WebSocket runtime is available; install 'ws' or pass webSocketFactory", + 'WEBSOCKET_UNAVAILABLE', + { cause }, + ); + } +} + +async function waitForOpen(socket: WebSocketLike, signal?: AbortSignal): Promise { + if (socket.readyState === 1) return; + if (socket.readyState !== 0) { + throw new ClusterTransportError('WebSocket is already closing or closed', 'OPEN_FAILED'); + } + if (signal?.aborted) throw new DOMException( + 'connect aborted locally; the server may still have committed this request', + 'AbortError', + ); + await new Promise((resolve, reject) => { + let settled = false; + const removers: Array<() => void> = []; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + for (const remove of removers) remove(); + signal?.removeEventListener('abort', onAbort); + fn(); + }; + const onAbort = () => settle(() => reject(new DOMException( + 'connect aborted locally; the server may still have committed this request', + 'AbortError', + ))); + removers.push( + addSocketListener(socket, 'open', () => settle(resolve)), + addSocketListener(socket, 'error', (error) => settle(() => reject(new ClusterTransportError('WebSocket failed to open', 'OPEN_FAILED', { cause: error })))), + addSocketListener(socket, 'close', () => settle(() => reject(new ClusterTransportError('WebSocket closed before opening', 'OPEN_FAILED')))), + ); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export async function connect(url: string, options: ConnectOptions = {}): Promise { + const factory = options.webSocketFactory ?? defaultWebSocketFactory; + let socket: WebSocketLike | undefined; + try { + socket = await factory(url, options.protocols); + await waitForOpen(socket, options.signal); + const connection = new Connection(socket); + await new ClusterClient(connection).initialize( + options.initialize, + options.signal === undefined ? {} : { signal: options.signal }, + ); + return connection; + } catch (error) { + if (socket) { + try { await socket.close(); } + catch { /* preserve the construction error */ } + } + throw error; + } +} diff --git a/src/cluster/connection.ts b/src/cluster/connection.ts new file mode 100644 index 00000000..d46c959d --- /dev/null +++ b/src/cluster/connection.ts @@ -0,0 +1,300 @@ +import { JSON_RPC_VERSION, MAX_FRAME_BYTES, SUBSCRIPTION_METHODS, UNARY_METHODS } from './generated/protocol.js'; +import type { + ClusterMethod, ClusterMethodParams, ClusterMethodResults, DomainErrorData, + SubscriptionMethod, UnaryClusterMethod, +} from './generated/protocol.js'; +import { + ClusterConfigError, ClusterInternalError, ClusterProtocolError, ClusterRpcError, + ClusterStateError, ClusterTimeoutError, ClusterTransportError, requestAbortError, +} from './errors.js'; +import { boundedFrameText, isRecord } from './frames.js'; +import type { FrameRecord } from './frames.js'; +import { BoundedQueue } from './queue.js'; +import { addSocketListener } from './socket.js'; +import type { WebSocketLike } from './socket.js'; +import { assertDefinition, assertMethodResult } from './validators.js'; +if (!Object.prototype.hasOwnProperty.call(Symbol, 'asyncDispose')) { + Object.defineProperty(Symbol, 'asyncDispose', { + configurable: false, enumerable: false, + value: Symbol.for('Symbol.asyncDispose'), writable: false, + }); +} + +export type ConnectionState = 'OPEN' | 'CLOSING' | 'CLOSED'; +export const CONNECTION_TRANSITIONS: Readonly> = Object.freeze({ + OPEN: Object.freeze(['CLOSING'] as const), + CLOSING: Object.freeze(['CLOSED'] as const), + CLOSED: Object.freeze([] as const), +}); +export const PROTOCOL_DIAGNOSTIC_CAPACITY = 128; +export interface CallOptions { readonly signal?: AbortSignal; readonly requestTimeoutMs?: number; } + +type Deferred = { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason: unknown) => void; +}; +function deferred(): Deferred { + let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; reject = onReject; + }); + return { promise, resolve, reject }; +} +export type SubscriptionKind = 'watch' | 'logs' | 'agent/attach'; +export type SubscriptionRegistration = { + readonly id: string; + readonly kind: SubscriptionKind; + readonly queue: BoundedQueue; + overflowed: boolean; + cancelSent: boolean; + abortHandler?: () => void; + abortSignal?: AbortSignal; +}; +export type EstablishedSubscription = { + readonly result: R; + readonly registration: SubscriptionRegistration; +}; +type PendingEntry = { + readonly id: string; readonly method: ClusterMethod; readonly expectedId: string; + readonly resolve: (value: unknown) => void; readonly reject: (reason: unknown) => void; + readonly subscriptionKind?: SubscriptionKind; settled: boolean; + abortHandler?: () => void; signal?: AbortSignal; timeout?: ReturnType; +}; + +export class Connection { + #state: ConnectionState = 'OPEN'; #sequence = 1; + readonly #pending = new Map(); + readonly #subscriptions = new Map(); + readonly #lateSubscriptionReapers = new Set(); + readonly #removeSocketListeners: Array<() => void> = []; + readonly #ownedSubscriptions = new WeakSet(); + #closePromise?: Promise; + readonly closeDiagnostics: unknown[] = []; + readonly protocolDiagnostics: ClusterProtocolError[] = []; + + readonly #socket: WebSocketLike; + constructor(socket: WebSocketLike) { + if (socket.readyState !== 1) throw new ClusterStateError('Connection requires an already-open WebSocket', 'SOCKET_NOT_OPEN'); + this.#socket = socket; + this.#removeSocketListeners.push( + addSocketListener(socket, 'message', (event) => this.#onMessage(event)), + addSocketListener(socket, 'error', () => { void this.#startClose(false); }), + addSocketListener(socket, 'close', () => { void this.#startClose(false); }), + ); + } + get state(): ConnectionState { return this.#state; } + get pendingSize(): number { return this.#pending.size; } + get subscriptionCount(): number { return this.#subscriptions.size; } + call(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise { + if (!(UNARY_METHODS as readonly string[]).includes(method)) { + throw new ClusterConfigError(`${method} is a subscription method`, 'INVALID_METHOD'); + } + return this.#dispatch(method, params, options) as Promise; + } + cancelSubscription(registration: SubscriptionRegistration): Promise { + if (!this.#ownedSubscriptions.has(registration)) { + return Promise.reject(new ClusterStateError('subscription is not owned by this connection', 'UNOWNED_SUBSCRIPTION')); + } + if (registration.cancelSent) return Promise.resolve(); + registration.cancelSent = true; + return this.#sendNotification('subscription/cancel', { subscriptionId: registration.id }); + } + openSubscription(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise> { + if (!(SUBSCRIPTION_METHODS as readonly string[]).includes(method)) { + throw new ClusterConfigError(`${method} is not a subscription method`, 'INVALID_METHOD'); + } + return this.#dispatch(method, params, options, method) as Promise>; + } + unregisterSubscription(id: string, registration: SubscriptionRegistration): void { + if (this.#subscriptions.get(id) !== registration) return; + this.#subscriptions.delete(id); + this.#disarmSubscriptionAbort(registration); + } + recordDiagnostic(error: unknown): void { this.closeDiagnostics.push(error); } + close(): Promise { return this.#startClose(true); } + + #dispatch(method: ClusterMethod, params: unknown, options: CallOptions, subscriptionKind?: SubscriptionKind): Promise { + this.#requireOpen(); + if (options.signal?.aborted) return Promise.reject(requestAbortError(method)); + if (options.requestTimeoutMs !== undefined && options.requestTimeoutMs < 0) return Promise.reject(new ClusterConfigError('requestTimeoutMs must be non-negative', 'INVALID_TIMEOUT')); + const id = this.#allocateId(); + const result = deferred(); + const entry: PendingEntry = { id, method, expectedId: id, resolve: result.resolve, reject: result.reject, settled: false, ...(subscriptionKind === undefined ? {} : { subscriptionKind }) }; + this.#pending.set(id, entry); + if (options.signal) { + const onAbort = () => { + if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry); + if (entry.subscriptionKind) this.#lateSubscriptionReapers.add(id); + entry.reject(requestAbortError(method)); + void this.#sendNotification('$/cancelRequest', { id }).catch((error: unknown) => this.recordDiagnostic(error)); + }; + entry.signal = options.signal; entry.abortHandler = onAbort; options.signal.addEventListener('abort', onAbort, { once: true }); + } + if (options.requestTimeoutMs !== undefined) { + entry.timeout = setTimeout(() => { + if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry); + if (entry.subscriptionKind) this.#lateSubscriptionReapers.add(id); + entry.reject(new ClusterTimeoutError(`${method} request timed out`, 'REQUEST_TIMEOUT')); + void this.#sendNotification('$/cancelRequest', { id }).catch((error: unknown) => this.recordDiagnostic(error)); + }, options.requestTimeoutMs); + } + const request = { jsonrpc: JSON_RPC_VERSION, id, method, params }; + void this.#sendFrame(request).catch((cause: unknown) => { + if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry); + entry.reject(new ClusterTransportError(`failed to send ${method}`, 'SEND_FAILED', { cause })); + }); + return result.promise; + } + #allocateId(): string { return `z${this.#sequence++}`; } + #removeExact(id: string, entry: PendingEntry): boolean { if (this.#pending.get(id) !== entry) return false; this.#pending.delete(id); return true; } + #settleEntry(entry: PendingEntry): void { + if (entry.settled) return; entry.settled = true; + if (entry.timeout !== undefined) clearTimeout(entry.timeout); + if (entry.signal && entry.abortHandler) entry.signal.removeEventListener('abort', entry.abortHandler); + } + #requireOpen(): void { if (this.#state !== 'OPEN') throw new ClusterStateError(`connection is ${this.#state.toLowerCase()}`, `CONNECTION_${this.#state}`); } + async #sendFrame(value: unknown, allowClosing = false): Promise { + if (this.#state === 'CLOSED' || (!allowClosing && this.#state !== 'OPEN')) throw new ClusterStateError(`connection is ${this.#state.toLowerCase()}`, `CONNECTION_${this.#state}`); + const payload = JSON.stringify(value); + if (this.#socket.send.length >= 2) { + await new Promise((resolve, reject) => { try { this.#socket.send(payload, (error?: Error) => error ? reject(error) : resolve()); } catch (error) { reject(error); } }); + return; + } + const sent = this.#socket.send(payload); if (sent && typeof (sent as Promise).then === 'function') await sent; + } + #sendNotification(method: '$/cancelRequest' | 'subscription/cancel', params: FrameRecord): Promise { + return this.#sendFrame({ jsonrpc: JSON_RPC_VERSION, method, params }, true); + } + #onMessage(event: unknown): void { + if (this.#state !== 'OPEN') return; + const inbound = boundedFrameText(event, MAX_FRAME_BYTES); + if (inbound.kind === 'unsupported') { this.#recordProtocolError('WebSocket message is not text or bytes'); return; } + if (inbound.kind === 'oversized') { this.#recordProtocolError(`frame exceeds ${MAX_FRAME_BYTES} bytes`); return; } + const { text, bytes } = inbound; + let frame: unknown; try { frame = JSON.parse(text); } catch (cause) { this.#recordProtocolError('invalid JSON frame', cause); return; } + if (!isRecord(frame)) { this.#recordProtocolError('invalid JSON-RPC frame'); return; } + if ('id' in frame) { this.#routeResponse(frame); return; } + if (frame.jsonrpc !== JSON_RPC_VERSION) { this.#recordProtocolError('invalid JSON-RPC frame'); return; } + if (typeof frame.method === 'string' && isRecord(frame.params)) { this.#routeNotification(frame.method, frame.params, frame, bytes); return; } + this.#recordProtocolError('unrecognized JSON-RPC frame'); + } + #routeResponse(frame: FrameRecord): void { + if (typeof frame.id !== 'string') { + this.#recordProtocolError('response id is not a string'); return; + } + const entry = this.#pending.get(frame.id); + if (!entry) { this.#reapLateSubscription(frame.id, frame.result); return; } + this.#removeExact(frame.id, entry); this.#settleEntry(entry); + if (frame.id !== entry.expectedId || frame.jsonrpc !== JSON_RPC_VERSION) { + entry.reject(new ClusterProtocolError('response identity mismatch', 'INVALID_RESPONSE_IDENTITY')); return; + } + if (isRecord(frame.error)) { this.#routeRpcError(entry, frame.error); return; } + this.#routeSuccess(entry, frame); + } + #reapLateSubscription(id: string, result: unknown): void { + if (!this.#lateSubscriptionReapers.delete(id) || !isRecord(result)) return; + if (typeof result.subscriptionId !== 'string') return; + void this.#sendNotification('subscription/cancel', { subscriptionId: result.subscriptionId }) + .catch((error: unknown) => this.recordDiagnostic(error)); + } + #routeRpcError(entry: PendingEntry, error: FrameRecord): void { + try { assertDefinition('JsonRpcError', error); } + catch (cause) { entry.reject(cause); return; } + const data = isRecord(error.data) ? error.data as DomainErrorData : undefined; + entry.reject(new ClusterRpcError(error.code as number, error.message as string, data)); + } + #routeSuccess(entry: PendingEntry, frame: FrameRecord): void { + if (!('result' in frame)) { + entry.reject(new ClusterProtocolError('response has neither result nor error', 'INVALID_RESPONSE')); return; + } + try { assertMethodResult(entry.method, frame.result); } + catch (error) { + if (entry.subscriptionKind && isRecord(frame.result) && typeof frame.result.subscriptionId === 'string') { + void this.#sendNotification('subscription/cancel', { subscriptionId: frame.result.subscriptionId }) + .catch((cause: unknown) => this.recordDiagnostic(cause)); + } + entry.reject(error); return; + } + if (!entry.subscriptionKind) { entry.resolve(frame.result); return; } + const result = frame.result as ClusterMethodResults[SubscriptionMethod]; + const registration: SubscriptionRegistration = { + id: result.subscriptionId, kind: entry.subscriptionKind, + queue: new BoundedQueue(), overflowed: false, cancelSent: false, + }; + if (this.#subscriptions.has(registration.id)) { + entry.reject(new ClusterProtocolError('duplicate subscriptionId', 'DUPLICATE_SUBSCRIPTION')); return; + } + this.#ownedSubscriptions.add(registration); + this.#subscriptions.set(registration.id, registration); + this.#armSubscriptionAbort(registration, entry.signal); + entry.resolve({ result, registration }); + } + #armSubscriptionAbort(registration: SubscriptionRegistration, signal: AbortSignal | undefined): void { + if (!signal) return; + const onAbort = () => { + if (this.#subscriptions.get(registration.id) !== registration) return; + this.unregisterSubscription(registration.id, registration); + registration.queue.closeAndDiscard(); + void this.cancelSubscription(registration).catch((error: unknown) => this.recordDiagnostic(error)); + }; + registration.abortSignal = signal; + registration.abortHandler = onAbort; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + #disarmSubscriptionAbort(registration: SubscriptionRegistration): void { + if (registration.abortSignal && registration.abortHandler) { + registration.abortSignal.removeEventListener('abort', registration.abortHandler); + } + delete registration.abortSignal; + delete registration.abortHandler; + } + #routeNotification(method: string, params: FrameRecord, frame: FrameRecord, bytes: number): void { + const subscriptionId = params.subscriptionId; + if (typeof subscriptionId !== 'string') { this.#recordProtocolError('subscription notification has no subscriptionId'); return; } + const registration = this.#subscriptions.get(subscriptionId); if (!registration) return; + const terminal = method === 'subscription/closed'; const outcome = registration.queue.push(frame, bytes); + if (outcome === 'overflow') { + registration.overflowed = true; this.unregisterSubscription(subscriptionId, registration); registration.queue.endRetainingBuffer(); + if (!terminal) void this.cancelSubscription(registration).catch((error: unknown) => this.recordDiagnostic(error)); + return; + } + if (terminal) { this.unregisterSubscription(subscriptionId, registration); registration.queue.endRetainingBuffer(); } + } + #recordProtocolError(message: string, cause?: unknown): void { + if (this.protocolDiagnostics.length === PROTOCOL_DIAGNOSTIC_CAPACITY) this.protocolDiagnostics.shift(); + this.protocolDiagnostics.push(new ClusterProtocolError(message, 'INVALID_PEER_FRAME', cause === undefined ? undefined : { cause })); + } + #startClose(sendCancels: boolean): Promise { + if (this.#closePromise) return this.#closePromise; if (this.#state === 'CLOSED') return Promise.resolve(); + this.#transition('CLOSING'); this.#closePromise = Promise.resolve().then(() => this.#finishClose(sendCancels)); return this.#closePromise; + } + async #finishClose(sendCancels: boolean): Promise { + const subscriptions = [...this.#subscriptions.values()]; this.#subscriptions.clear(); + for (const subscription of subscriptions) { + this.#disarmSubscriptionAbort(subscription); + subscription.queue.closeAndDiscard(); + } + const pending = [...this.#pending.values()]; this.#pending.clear(); + for (const entry of pending) { this.#settleEntry(entry); entry.reject(new ClusterTransportError('connection closed', 'CONNECTION_CLOSED')); } + this.#lateSubscriptionReapers.clear(); + if (sendCancels) for (const subscription of subscriptions) { + try { await this.cancelSubscription(subscription); } + catch (error) { this.recordDiagnostic(error); } + } + try { await this.#socket.close(); } catch (error) { this.recordDiagnostic(error); } + for (const remove of this.#removeSocketListeners.splice(0)) { + try { remove(); } catch (error) { this.recordDiagnostic(error); } + } + this.#transition('CLOSED'); + } + #transition(to: ConnectionState): void { + if (!CONNECTION_TRANSITIONS[this.#state].includes(to)) throw new ClusterInternalError(`illegal connection transition ${this.#state} -> ${to}`, 'ILLEGAL_STATE_TRANSITION'); + this.#state = to; + } +} + +Object.defineProperty(Connection.prototype, Symbol.asyncDispose, { + configurable: true, value: Connection.prototype.close, +}); diff --git a/src/cluster/errors.ts b/src/cluster/errors.ts new file mode 100644 index 00000000..910e0b69 --- /dev/null +++ b/src/cluster/errors.ts @@ -0,0 +1,32 @@ +import type { DomainErrorData } from './generated/protocol.js'; + +export class ClusterError extends Error { + constructor(message: string, readonly code: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +export class ClusterConfigError extends ClusterError {} +export class ClusterStateError extends ClusterError {} +export class ClusterInternalError extends ClusterError {} +export class ClusterProtocolError extends ClusterError {} +export class ClusterTransportError extends ClusterError {} +export class ClusterTimeoutError extends ClusterError {} + +export function requestAbortError(method: string): DOMException { + return new DOMException( + `${method} aborted locally; the server may still have committed this request`, + 'AbortError', + ); +} + +export class ClusterRpcError extends ClusterError { + constructor( + readonly rpcCode: number, + message: string, + readonly data?: DomainErrorData, + ) { + super(message, data?.code ?? String(rpcCode)); + } +} diff --git a/src/cluster/frames.ts b/src/cluster/frames.ts new file mode 100644 index 00000000..55c8840c --- /dev/null +++ b/src/cluster/frames.ts @@ -0,0 +1,44 @@ +export type FrameRecord = Readonly>; + +export type BoundedFrameText = + | { readonly kind: 'frame'; readonly text: string; readonly bytes: number } + | { readonly kind: 'oversized' } + | { readonly kind: 'unsupported' }; + +function boundedUtf8Length(value: string, maximum: number): number | undefined { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + if (codePoint === undefined) break; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else { bytes += 4; index += 1; } + if (bytes > maximum) return undefined; + } + return bytes; +} + +export function boundedFrameText(event: unknown, maximum: number): BoundedFrameText { + const candidate = event && typeof event === 'object' && 'data' in event + ? event.data + : event; + if (typeof candidate === 'string') { + const bytes = boundedUtf8Length(candidate, maximum); + return bytes === undefined ? { kind: 'oversized' } : { kind: 'frame', text: candidate, bytes }; + } + if (candidate instanceof ArrayBuffer) { + if (candidate.byteLength > maximum) return { kind: 'oversized' }; + return { kind: 'frame', text: new TextDecoder().decode(candidate), bytes: candidate.byteLength }; + } + if (ArrayBuffer.isView(candidate)) { + if (candidate.byteLength > maximum) return { kind: 'oversized' }; + const view = new Uint8Array(candidate.buffer, candidate.byteOffset, candidate.byteLength); + return { kind: 'frame', text: new TextDecoder().decode(view), bytes: candidate.byteLength }; + } + return { kind: 'unsupported' }; +} + +export function isRecord(value: unknown): value is FrameRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/cluster/generated/protocol-schema.ts b/src/cluster/generated/protocol-schema.ts new file mode 100644 index 00000000..154a39e3 --- /dev/null +++ b/src/cluster/generated/protocol-schema.ts @@ -0,0 +1,2 @@ +// Generated from protocol/openengine-cluster/v1/schema.json. Do not edit. +export const CLUSTER_PROTOCOL_SCHEMA: unknown = {"$defs":{"AdmissionTransition":{"additionalProperties":false,"properties":{"runId":{"type":"string"},"seedInput":true,"spec":{"$ref":"#/$defs/GraphSpec"}},"required":["runId","spec","seedInput"],"type":"object"},"AgentAttachClosedNotification":{"additionalProperties":false,"description":"Wire body of the terminal `subscription/closed` server notification for an `agent/attach`\nsubscription. Deliberately carries no cursor field -- `agent/attach` gives a type-level\n\"cursorless\" guarantee, unlike [`crate::SubscriptionClosedNotification`].","properties":{"reason":{"$ref":"#/$defs/SubscriptionCloseReason"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","reason"],"type":"object"},"AgentAttachEvent":{"description":"The closed public agent-attach progress algebra. This is the only representable shape:\nreasoning, tools, provider frames, usage, and session identifiers have no variant. `Working`\nand `Settled` are empty struct variants rather than bare units: serde's internally tagged enum\ndeserialization silently ignores unknown fields on a unit variant regardless of\n`deny_unknown_fields`, which would otherwise let an unrepresentable field ride along\nundetected on either of these two variants.","oneOf":[{"additionalProperties":false,"properties":{"type":{"const":"working","type":"string"}},"required":["type"],"type":"object"},{"additionalProperties":false,"properties":{"text":{"maxLength":16384,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]*$","type":"string"},"type":{"const":"output","type":"string"}},"required":["type","text"],"type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"settled","type":"string"}},"required":["type"],"type":"object"}]},"AgentAttachEventNotification":{"$ref":"#/$defs/AgentAttachEventNotificationWire"},"AgentAttachEventNotificationWire":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/AgentAttachEvent"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","event"],"type":"object"},"AgentAttachParams":{"additionalProperties":false,"description":"`agent/attach` establishment parameters: the named `{execution}` request. Deliberately closed,\nrejecting any unknown field.","properties":{"execution":{"maxLength":128,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"}},"required":["execution"],"type":"object"},"AgentAttachResult":{"additionalProperties":false,"description":"The `agent/attach` establishment result: only a `subscriptionId`. Deliberately carries no\n`runId` or `atCursor` -- `agent/attach` is not run-scoped and has no cursor.","properties":{"subscriptionId":{"type":"string"}},"required":["subscriptionId"],"type":"object"},"ApplyParams":{"additionalProperties":false,"properties":{"dryRun":{"default":false,"type":"boolean"},"graph":{"$ref":"#/$defs/GraphSpec"},"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"input":true},"required":["graph"],"type":"object"},"ApplyResult":{"additionalProperties":false,"properties":{"deduped":{"type":"boolean"},"diff":{"anyOf":[{"$ref":"#/$defs/GraphDiff"},{"type":"null"}]},"generation":{"maximum":9007199254740991,"minimum":0,"type":["integer","null"]},"phase":{"$ref":"#/$defs/Phase"},"runId":{"type":["string","null"]}},"required":["phase","deduped"],"type":"object"},"ArtifactLineage":{"additionalProperties":false,"properties":{"attempt":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"generation":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"runId":{"type":"string"}},"required":["generation","runId","attempt"],"type":"object"},"ArtifactProducer":{"additionalProperties":false,"properties":{"node":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"worker":{"maxLength":256,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*@[1-9][0-9]*$","type":"string"}},"required":["node","worker"],"type":"object"},"ArtifactRef":{"additionalProperties":false,"properties":{"artifactId":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"byteLength":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"lineage":{"$ref":"#/$defs/ArtifactLineage"},"mediaType":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"producer":{"$ref":"#/$defs/ArtifactProducer"},"redaction":{"$ref":"#/$defs/RedactionClass"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"typeId":{"maxLength":256,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*@[1-9][0-9]*$","type":"string"}},"required":["artifactId","sha256","byteLength","mediaType","typeId","producer","lineage","redaction"],"type":"object"},"BackendFault":{"$ref":"#/$defs/BackendFaultWire"},"BackendFaultWire":{"additionalProperties":false,"properties":{"action":{"$ref":"#/$defs/FaultAction"},"code":{"$ref":"#/$defs/FaultCode"},"consequence":{"$ref":"#/$defs/FaultConsequence"},"eventId":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"executionRef":{"default":null,"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":["string","null"]},"retry":{"$ref":"#/$defs/FaultRetryDisposition"},"severity":{"$ref":"#/$defs/FaultSeverity"},"source":{"items":{"$ref":"#/$defs/FaultSourceFrame"},"maxItems":8,"type":"array"},"summary":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"}},"required":["eventId","code","consequence","retry","action","severity","summary","source"],"type":"object"},"CancelRequestParams":{"additionalProperties":false,"description":"Wire body of the `$/cancelRequest` client notification: best-effort cancellation of an\nin-flight unary request by its `RequestId`. Unknown or already-completed ids are a silent\nno-op; cancelling after the backend has committed leaves that committed state unchanged and\nemits no response or compensation.","properties":{"id":{"$ref":"#/$defs/RequestId"}},"required":["id"],"type":"object"},"ChoiceBranch":{"additionalProperties":false,"properties":{"node":{"$ref":"#/$defs/GraphNode"},"when":{"$ref":"#/$defs/Guard"}},"required":["when","node"],"type":"object"},"ClusterStatus":{"properties":{"atCursor":{"type":["string","null"]},"currentRunId":{"type":["string","null"]},"observedGeneration":{"maximum":9007199254740991,"minimum":0,"type":["integer","null"]},"operational":{"anyOf":[{"$ref":"#/$defs/OperationalStatus"},{"type":"null"}]},"phase":{"$ref":"#/$defs/Phase"}},"required":["phase"],"type":"object"},"ControlSelector":{"additionalProperties":false,"properties":{"field":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":["string","null"]},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"source":{"$ref":"#/$defs/ControlSource"}},"required":["name","source"],"type":"object"},"ControlSource":{"enum":["signal","error","group"],"type":"string"},"DataSelector":{"oneOf":[{"additionalProperties":false,"properties":{"path":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"source":{"const":"state","type":"string"}},"required":["source","path"],"type":"object"},{"additionalProperties":false,"properties":{"path":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"source":{"const":"item","type":"string"}},"required":["source","path"],"type":"object"}]},"DeleteParams":{"additionalProperties":false,"properties":{"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"ifRunId":{"type":["string","null"]}},"required":["ifGeneration","idempotencyKey"],"type":"object"},"DeleteResult":{"additionalProperties":false,"properties":{"atCursor":{"type":["string","null"]},"deduped":{"type":"boolean"},"deleted":{"type":"boolean"},"generation":{"maximum":9007199254740991,"minimum":0,"type":["integer","null"]},"phase":{"$ref":"#/$defs/Phase"},"runId":{"type":["string","null"]}},"required":["deleted","phase","deduped"],"type":"object"},"DiagnosticPathSegment":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"field","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"}},"required":["kind","name"],"type":"object"},{"additionalProperties":false,"properties":{"index":{"format":"uint32","maximum":4294967295,"minimum":0,"type":"integer"},"kind":{"const":"index","type":"string"}},"required":["kind","index"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"node","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"}},"required":["kind","name"],"type":"object"}]},"DiagnosticSeverity":{"enum":["error","warning","info"],"type":"string"},"DispatchState":{"enum":["active","suspended","draining","force_stopping","stopped"],"type":"string"},"DomainErrorData":{"properties":{"code":{"type":"string"},"details":true},"required":["code"],"type":"object"},"EventNotification":{"additionalProperties":false,"description":"Wire body of the generic `event` server notification.","properties":{"cursor":{"type":"string"},"event":{"$ref":"#/$defs/WatchEvent"},"runId":{"type":"string"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","runId","cursor","event"],"type":"object"},"FaultAction":{"description":"Descriptive only, like [`FaultRetryDisposition`]: naming `Retry` never itself retries or\nauthorizes a retry.","enum":["none","retry","wait","escalate","abort"],"type":"string"},"FaultCode":{"enum":["unavailable","resource_exhausted","deadline_exceeded","permission_denied","failed_precondition","not_found","aborted","internal","unknown"],"type":"string"},"FaultConsequence":{"enum":["turn_failed","run_failed","run_degraded","no_observable_effect"],"type":"string"},"FaultRetryDisposition":{"description":"Descriptive only: no `BackendFault` and no `fault` event ever performs or authorizes a retry.\nEvent ordering and emission never themselves change terminal semantics.","enum":["retryable","retryable_after_backoff","not_retryable","indeterminate"],"type":"string"},"FaultSeverity":{"enum":["info","warning","error","critical"],"type":"string"},"FaultSourceFrame":{"additionalProperties":false,"properties":{"component":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"}},"required":["component"],"type":"object"},"GetParams":{"additionalProperties":false,"properties":{"atCursor":{"default":null,"type":["string","null"]}},"type":"object"},"GetResult":{"properties":{"atCursor":{"type":["string","null"]},"spec":{"anyOf":[{"$ref":"#/$defs/GraphSpec"},{"type":"null"}]},"status":{"$ref":"#/$defs/ClusterStatus"}},"required":["status"],"type":"object"},"GraphDiagnostic":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/GraphDiagnosticCode"},"message":{"type":"string"},"path":{"items":{"$ref":"#/$defs/DiagnosticPathSegment"},"type":"array"},"relatedNodes":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"array"},"severity":{"$ref":"#/$defs/DiagnosticSeverity"}},"required":["severity","code","message","path","relatedNodes"],"type":"object"},"GraphDiagnosticCode":{"enum":["schema_safety","reachability","choice_exhaustiveness","loop_exit_satisfiability","missing_bound","write_conflict","ceiling_exceeded","cyclic_reference","undefined_read","invalid_graph_shape"],"type":"string"},"GraphDiff":{"additionalProperties":false,"properties":{"added":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"array"},"changed":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"array"},"removed":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"array"}},"required":["added","removed","changed"],"type":"object"},"GraphNode":{"oneOf":[{"additionalProperties":false,"properties":{"attempts":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"input":{"$ref":"#/$defs/PayloadType"},"inputBindings":{"items":{"$ref":"#/$defs/InputBinding"},"type":"array"},"kind":{"const":"step","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"output":{"$ref":"#/$defs/PayloadType"},"timeoutMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"worker":{"maxLength":256,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*@[1-9][0-9]*$","type":"string"},"writeBindings":{"items":{"$ref":"#/$defs/WriteBinding"},"type":"array"}},"required":["kind","name","worker","input","output","inputBindings","writeBindings","timeoutMs","attempts"],"type":"object"},{"additionalProperties":false,"properties":{"attempts":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"diagnostic":{"$ref":"#/$defs/PayloadType"},"input":{"$ref":"#/$defs/PayloadType"},"inputBindings":{"items":{"$ref":"#/$defs/InputBinding"},"type":"array"},"kind":{"const":"verifier","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"output":{"$ref":"#/$defs/PayloadType"},"signals":{"additionalProperties":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array","uniqueItems":true},"propertyNames":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"object"},"timeoutMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"worker":{"maxLength":256,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*@[1-9][0-9]*$","type":"string"},"writeBindings":{"items":{"$ref":"#/$defs/WriteBinding"},"type":"array"}},"required":["kind","name","worker","input","output","inputBindings","writeBindings","timeoutMs","attempts","signals","diagnostic"],"type":"object"},{"additionalProperties":false,"properties":{"children":{"$ref":"#/$defs/NonEmptyVec_of_GraphNode"},"kind":{"const":"seq","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"promotedStatePaths":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"type":"array"},"state":{"$ref":"#/$defs/PayloadType"}},"required":["kind","name","state","children","promotedStatePaths"],"type":"object"},{"additionalProperties":false,"properties":{"branches":{"$ref":"#/$defs/NonEmptyVec_of_ChoiceBranch"},"kind":{"const":"choice","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"otherwise":{"anyOf":[{"$ref":"#/$defs/GraphNode"},{"type":"null"}]},"promotedStatePaths":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"type":"array"},"state":{"$ref":"#/$defs/PayloadType"}},"required":["kind","name","state","branches","promotedStatePaths"],"type":"object"},{"additionalProperties":false,"properties":{"branches":{"$ref":"#/$defs/NonEmptyVec_of_GraphNode"},"join":{"$ref":"#/$defs/Join"},"kind":{"const":"par","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"promotedStatePaths":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"type":"array"},"state":{"$ref":"#/$defs/PayloadType"}},"required":["kind","name","state","branches","promotedStatePaths","join"],"type":"object"},{"additionalProperties":false,"properties":{"body":{"$ref":"#/$defs/GraphNode"},"kind":{"const":"loop","type":"string"},"maxIterations":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"promotedStatePaths":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"type":"array"},"state":{"$ref":"#/$defs/PayloadType"},"until":{"$ref":"#/$defs/Guard"}},"required":["kind","name","state","body","until","maxIterations","promotedStatePaths"],"type":"object"},{"additionalProperties":false,"properties":{"body":{"$ref":"#/$defs/GraphNode"},"kind":{"const":"map","type":"string"},"maxItems":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"over":{"$ref":"#/$defs/DataSelector"},"promotedStatePaths":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"type":"array"},"state":{"$ref":"#/$defs/PayloadType"}},"required":["kind","name","state","body","over","maxItems","promotedStatePaths"],"type":"object"},{"additionalProperties":false,"properties":{"bindings":{"items":{"$ref":"#/$defs/InputBinding"},"type":"array"},"kind":{"const":"succeed","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"output":{"$ref":"#/$defs/PayloadType"}},"required":["kind","name","output","bindings"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"fail","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"reason":{"maxLength":128,"minLength":1,"pattern":"^(?!unhandled$)[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"}},"required":["kind","name","reason"],"type":"object"}]},"GraphProfile":{"enum":["openengine.graph.full/v1","openengine.graph.single-worker/v1"],"type":"string"},"GraphSpec":{"additionalProperties":false,"properties":{"initialInput":{"$ref":"#/$defs/PayloadType"},"policy":{"$ref":"#/$defs/PolicyBinding"},"profile":{"$ref":"#/$defs/GraphProfile"},"root":{"$ref":"#/$defs/GraphNode"}},"required":["profile","initialInput","policy","root"],"type":"object"},"Guard":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"in","type":"string"},"labels":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array","uniqueItems":true},"value":{"$ref":"#/$defs/ControlSelector"}},"required":["kind","value","labels"],"type":"object"},{"additionalProperties":false,"properties":{"guards":{"$ref":"#/$defs/NonEmptyVec_of_Guard"},"kind":{"const":"all","type":"string"}},"required":["kind","guards"],"type":"object"},{"additionalProperties":false,"properties":{"guards":{"$ref":"#/$defs/NonEmptyVec_of_Guard"},"kind":{"const":"any","type":"string"}},"required":["kind","guards"],"type":"object"},{"additionalProperties":false,"properties":{"guard":{"$ref":"#/$defs/Guard"},"kind":{"const":"not","type":"string"}},"required":["kind","guard"],"type":"object"},{"additionalProperties":false,"properties":{"count":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"kind":{"const":"k_of_n","type":"string"},"labels":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array","uniqueItems":true},"values":{"$ref":"#/$defs/NonEmptyVec_of_ControlSelector"}},"required":["kind","count","values","labels"],"type":"object"},{"additionalProperties":false,"properties":{"count":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"kind":{"const":"k_of_map","type":"string"},"labels":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array","uniqueItems":true},"value":{"$ref":"#/$defs/ControlSelector"}},"required":["kind","count","value","labels"],"type":"object"}]},"InitializeParams":{"additionalProperties":false,"properties":{"protocolVersion":{"const":"openengine.cluster/v1","type":"string"}},"required":["protocolVersion"],"type":"object"},"InitializeResult":{"properties":{"capabilities":{"$ref":"#/$defs/ServerCapabilities"},"protocolVersion":{"const":"openengine.cluster/v1","type":"string"},"status":{"$ref":"#/$defs/ClusterStatus"}},"required":["protocolVersion","capabilities","status"],"type":"object"},"InputBinding":{"additionalProperties":false,"properties":{"target":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"value":{"$ref":"#/$defs/DataSelector"}},"required":["target","value"],"type":"object"},"Join":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"all","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"any","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"count":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"kind":{"const":"quorum","type":"string"}},"required":["kind","count"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"first","type":"string"},"when":{"$ref":"#/$defs/Guard"}},"required":["kind","when"],"type":"object"}]},"JsonRpcError":{"properties":{"code":{"format":"int64","type":"integer"},"data":{"anyOf":[{"$ref":"#/$defs/DomainErrorData"},{"type":"null"}]},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"JsonRpcErrorResponse":{"properties":{"error":{"$ref":"#/$defs/JsonRpcError"},"id":{"anyOf":[{"$ref":"#/$defs/RequestId"},{"type":"null"}]},"jsonrpc":{"type":"string"}},"required":["jsonrpc","error"],"type":"object"},"JsonRpcNotification":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/EventNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification2":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/SubscriptionCancelParams"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification3":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/SubscriptionClosedNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification4":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/CancelRequestParams"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification5":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/LogEventNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification6":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/LogsClosedNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification7":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/AgentAttachEventNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcNotification8":{"properties":{"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/AgentAttachClosedNotification"}},"required":["jsonrpc","method","params"],"type":"object"},"JsonRpcRequest":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/InitializeParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest10":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/WatchParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest11":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/LogsParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest12":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/AgentAttachParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest2":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/PlanParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest3":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/ApplyParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest4":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/GetParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest5":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/UpdateParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest6":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/StopParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest7":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/RetryParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest8":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/ResubmitParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcRequest9":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"method":{"type":"string"},"params":{"$ref":"#/$defs/DeleteParams"}},"required":["jsonrpc","id","method","params"],"type":"object"},"JsonRpcResponse":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse10":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess10"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse11":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess11"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse12":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess12"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse2":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess2"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse3":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess3"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse4":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess4"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse5":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess5"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse6":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess6"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse7":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess7"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse8":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess8"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcResponse9":{"anyOf":[{"$ref":"#/$defs/JsonRpcSuccess9"},{"$ref":"#/$defs/JsonRpcErrorResponse"}]},"JsonRpcSuccess":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/InitializeResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess10":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/WatchResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess11":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/LogsResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess12":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/AgentAttachResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess2":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/PlanResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess3":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/ApplyResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess4":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/GetResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess5":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/UpdateResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess6":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/StopResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess7":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/RetryResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess8":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/ResubmitResult"}},"required":["jsonrpc","id","result"],"type":"object"},"JsonRpcSuccess9":{"properties":{"id":{"$ref":"#/$defs/RequestId"},"jsonrpc":{"type":"string"},"result":{"$ref":"#/$defs/DeleteResult"}},"required":["jsonrpc","id","result"],"type":"object"},"Labels":{"additionalProperties":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"maxProperties":64,"propertyNames":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"type":"object"},"LogEventNotification":{"$ref":"#/$defs/LogEventNotificationWire"},"LogEventNotificationWire":{"additionalProperties":false,"properties":{"record":{"$ref":"#/$defs/LogRecord"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","record"],"type":"object"},"LogLevel":{"enum":["trace","debug","info","warn","error"],"type":"string"},"LogRecord":{"additionalProperties":false,"description":"The closed public log record shape: a level, a bounded target, and a bounded (possibly\nredacted) message. No raw bytes, reasoning, tools, credentials, env, or provider/session IDs\nare representable.","properties":{"level":{"$ref":"#/$defs/LogLevel"},"message":{"maxLength":16384,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]*$","type":"string"},"target":{"maxLength":128,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"}},"required":["level","target","message"],"type":"object"},"LogsClosedNotification":{"additionalProperties":false,"description":"Wire body of the terminal `subscription/closed` server notification for a `logs` subscription.\nDeliberately carries no cursor field -- `logs` gives a type-level \"cursorless\" guarantee, unlike\n[`crate::SubscriptionClosedNotification`].","properties":{"reason":{"$ref":"#/$defs/SubscriptionCloseReason"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","reason"],"type":"object"},"LogsParams":{"additionalProperties":false,"description":"`logs` establishment parameters. v1 has zero caller filters: this is deliberately empty and\nclosed, rejecting any unknown field.","type":"object"},"LogsResult":{"additionalProperties":false,"description":"The `logs` establishment result: only a `subscriptionId`. Deliberately carries no `runId` or\n`atCursor` -- `logs` is not run-scoped and has no cursor.","properties":{"subscriptionId":{"type":"string"}},"required":["subscriptionId"],"type":"object"},"NodeAddress":{"additionalProperties":false,"properties":{"attempt":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"node":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"}},"required":["node","attempt"],"type":"object"},"NodeOutputChannel":{"enum":["out","signal","diagnostic"],"type":"string"},"NodeOutputSelector":{"additionalProperties":false,"properties":{"channel":{"$ref":"#/$defs/NodeOutputChannel"},"node":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"path":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"}},"required":["node","channel","path"],"type":"object"},"NonEmptyVec_of_ChoiceBranch":{"items":{"$ref":"#/$defs/ChoiceBranch"},"maxItems":4096,"minItems":1,"type":"array"},"NonEmptyVec_of_ControlSelector":{"items":{"$ref":"#/$defs/ControlSelector"},"maxItems":4096,"minItems":1,"type":"array"},"NonEmptyVec_of_FieldPath":{"items":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"maxItems":4096,"minItems":1,"type":"array"},"NonEmptyVec_of_GraphNode":{"items":{"$ref":"#/$defs/GraphNode"},"maxItems":4096,"minItems":1,"type":"array"},"NonEmptyVec_of_Guard":{"items":{"$ref":"#/$defs/Guard"},"maxItems":4096,"minItems":1,"type":"array"},"NonEmptyVec_of_NodeName":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array"},"OperationalStatus":{"additionalProperties":false,"properties":{"dispatchState":{"$ref":"#/$defs/DispatchState"},"inFlight":{"format":"uint32","minimum":0,"type":"integer"},"labels":{"$ref":"#/$defs/Labels"},"logLevel":{"$ref":"#/$defs/LogLevel"},"stopMode":{"anyOf":[{"$ref":"#/$defs/StopMode"},{"type":"null"}]}},"required":["labels","logLevel","dispatchState","inFlight"],"type":"object"},"PayloadType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"null","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"number","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"fields":{"additionalProperties":{"$ref":"#/$defs/RecordField"},"propertyNames":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"object"},"kind":{"const":"record","type":"string"}},"required":["kind","fields"],"type":"object"},{"additionalProperties":false,"properties":{"items":{"$ref":"#/$defs/PayloadType"},"kind":{"const":"array","type":"string"}},"required":["kind","items"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":4096,"minItems":1,"type":"array","uniqueItems":true}},"required":["kind","values"],"type":"object"}]},"Phase":{"enum":["empty","admitting","running","finished","deleting"],"type":"string"},"PlanParams":{"additionalProperties":false,"properties":{"graph":{"$ref":"#/$defs/GraphSpec"}},"required":["graph"],"type":"object"},"PlanResult":{"additionalProperties":false,"properties":{"bounds":{"anyOf":[{"$ref":"#/$defs/StructuralBounds"},{"type":"null"}]},"diagnostics":{"items":{"$ref":"#/$defs/GraphDiagnostic"},"type":"array"},"ok":{"type":"boolean"}},"required":["ok","diagnostics"],"type":"object"},"PolicyBinding":{"additionalProperties":false,"properties":{"default":{"$ref":"#/$defs/PolicyDefault"},"policy":{"maxLength":256,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*@[1-9][0-9]*$","type":"string"}},"required":["policy","default"],"type":"object"},"PolicyDefault":{"enum":["deny"],"type":"string"},"RecordField":{"additionalProperties":false,"properties":{"required":{"type":"boolean"},"type":{"$ref":"#/$defs/PayloadType"}},"required":["type","required"],"type":"object"},"RedactionClass":{"enum":["public","internal","confidential","restricted"],"type":"string"},"RequestId":{"anyOf":[{"type":"string"},{"format":"int64","type":"integer"}]},"ResubmitParams":{"additionalProperties":false,"properties":{"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"ifRunId":{"type":"string"},"replacementInput":true},"required":["ifGeneration","ifRunId","idempotencyKey"],"type":"object"},"ResubmitResult":{"additionalProperties":false,"properties":{"atCursor":{"type":"string"},"deduped":{"type":"boolean"},"generation":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"operational":{"$ref":"#/$defs/OperationalStatus"},"phase":{"$ref":"#/$defs/Phase"},"priorRunId":{"type":"string"},"runId":{"type":"string"}},"required":["generation","priorRunId","runId","phase","operational","atCursor","deduped"],"type":"object"},"RetryParams":{"additionalProperties":false,"properties":{"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["ifGeneration","idempotencyKey"],"type":"object"},"RetryResult":{"additionalProperties":false,"properties":{"atCursor":{"type":"string"},"deduped":{"type":"boolean"},"generation":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"operational":{"$ref":"#/$defs/OperationalStatus"},"phase":{"$ref":"#/$defs/Phase"},"retriedTurnId":{"type":"string"},"retryTurnId":{"type":"string"},"runId":{"type":"string"}},"required":["generation","runId","phase","retriedTurnId","retryTurnId","operational","atCursor","deduped"],"type":"object"},"ServerCapabilities":{"additionalProperties":false,"properties":{"agentAttach":{"default":false,"type":"boolean"},"graphProfiles":{"default":[],"items":{"$ref":"#/$defs/GraphProfile"},"maxItems":2,"not":{"minItems":2,"prefixItems":[{"const":"openengine.graph.single-worker/v1"},{"const":"openengine.graph.full/v1"}]},"type":"array","uniqueItems":true},"logs":{"default":false,"type":"boolean"}},"type":"object"},"StopMode":{"enum":["drain","force"],"type":"string"},"StopParams":{"additionalProperties":false,"properties":{"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"mode":{"$ref":"#/$defs/StopMode"}},"required":["mode","ifGeneration","idempotencyKey"],"type":"object"},"StopResult":{"additionalProperties":false,"properties":{"acceptedMode":{"$ref":"#/$defs/StopMode"},"atCursor":{"type":"string"},"deduped":{"type":"boolean"},"effectiveMode":{"$ref":"#/$defs/StopMode"},"generation":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"operational":{"$ref":"#/$defs/OperationalStatus"},"phase":{"$ref":"#/$defs/Phase"},"runId":{"type":"string"}},"required":["generation","runId","phase","acceptedMode","effectiveMode","operational","atCursor","deduped"],"type":"object"},"StructuralBounds":{"additionalProperties":false,"properties":{"attemptsPerNode":{"additionalProperties":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"propertyNames":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"object"},"maxNodeExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"peakConcurrency":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"termination":{"$ref":"#/$defs/TerminationWitness"}},"required":["termination","maxNodeExecutions","peakConcurrency","attemptsPerNode"],"type":"object"},"SubscriptionCancelParams":{"additionalProperties":false,"description":"Wire body of the generic `subscription/cancel` client notification.","properties":{"subscriptionId":{"type":"string"}},"required":["subscriptionId"],"type":"object"},"SubscriptionCloseReason":{"enum":["done","SLOW_CONSUMER"],"type":"string"},"SubscriptionClosedNotification":{"additionalProperties":false,"description":"Wire body of the terminal `subscription/closed` server notification.","properties":{"lastDeliveredCursor":{"type":["string","null"]},"reason":{"$ref":"#/$defs/SubscriptionCloseReason"},"subscriptionId":{"type":"string"}},"required":["subscriptionId","reason"],"type":"object"},"TerminationWitness":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"acyclic","type":"string"},"order":{"$ref":"#/$defs/NonEmptyVec_of_NodeName"}},"required":["kind","order"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"bounded","type":"string"},"maxIterations":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"ranking":{"$ref":"#/$defs/NonEmptyVec_of_FieldPath"}},"required":["kind","ranking","maxIterations"],"type":"object"}]},"UpdateParams":{"additionalProperties":false,"anyOf":[{"required":["labels"]},{"required":["logLevel"]},{"required":["suspended"]}],"properties":{"idempotencyKey":{"maxLength":256,"minLength":1,"pattern":"^[^\\u0000-\\u001f\\u007f-\\u009f]+$","type":"string"},"ifGeneration":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"labels":{"$ref":"#/$defs/Labels"},"logLevel":{"$ref":"#/$defs/LogLevel"},"suspended":{"type":"boolean"}},"required":["ifGeneration","idempotencyKey"],"type":"object"},"UpdateResult":{"additionalProperties":false,"properties":{"atCursor":{"type":"string"},"deduped":{"type":"boolean"},"generation":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"operational":{"$ref":"#/$defs/OperationalStatus"},"phase":{"$ref":"#/$defs/Phase"},"runId":{"type":"string"}},"required":["generation","runId","phase","operational","atCursor","deduped"],"type":"object"},"WatchEvent":{"description":"The closed public event algebra. `Phase` folds the observable cluster status (admission\ncommit, update, suspend/resume, stop-request); `NodeBegin`/`NodeEnd` are a testkit-only\nsynthetic hook decoupled from the real dispatch/lease turn mechanism, since no native graph\nexecutor exists yet; `Bookmark` advances the cursor without changing folded public state;\n`Fault` carries a durable, backend-neutral projected `BackendFault`: it correlates to the\nenclosing `EventNotification.run_id` plus its own optional opaque `executionRef`, and its\nordering/emission never itself authorizes a retry or changes terminal semantics -- it folds to\nno public status change, exactly like `Bookmark`; `Finished` is always the last event for a\nrun.","oneOf":[{"additionalProperties":false,"properties":{"admission":{"anyOf":[{"$ref":"#/$defs/AdmissionTransition"},{"type":"null"}]},"status":{"$ref":"#/$defs/ClusterStatus"},"type":{"const":"phase","type":"string"}},"required":["type","status"],"type":"object"},{"additionalProperties":false,"properties":{"input":true,"node":{"$ref":"#/$defs/NodeAddress"},"type":{"const":"node_begin","type":"string"}},"required":["type","node","input"],"type":"object"},{"additionalProperties":false,"properties":{"node":{"$ref":"#/$defs/NodeAddress"},"outcome":{"$ref":"#/$defs/WorkerOutcome"},"type":{"const":"node_end","type":"string"}},"required":["type","node","outcome"],"type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"bookmark","type":"string"}},"required":["type"],"type":"object"},{"additionalProperties":false,"properties":{"fault":{"$ref":"#/$defs/BackendFault"},"type":{"const":"fault","type":"string"}},"required":["type","fault"],"type":"object"},{"additionalProperties":false,"properties":{"final_status":{"$ref":"#/$defs/ClusterStatus"},"stop_mode":{"anyOf":[{"$ref":"#/$defs/StopMode"},{"type":"null"}]},"type":{"const":"finished","type":"string"}},"required":["type","final_status"],"type":"object"}]},"WatchParams":{"additionalProperties":false,"properties":{"fromCursor":{"default":null,"type":["string","null"]},"runId":{"default":null,"type":["string","null"]}},"type":"object"},"WatchResult":{"properties":{"atCursor":{"type":["string","null"]},"runId":{"type":["string","null"]},"subscriptionId":{"type":"string"}},"required":["subscriptionId"],"type":"object"},"WorkerErrorCode":{"enum":["timeout","crash","malformed","refusal"],"type":"string"},"WorkerFailureReason":{"enum":["declared_failure","policy_denied","interactive_input_required","authentication_required","malformed_result"],"type":"string"},"WorkerOutcome":{"allOf":[{"$ref":"#/$defs/WorkerOutcomeWire"},{"if":{"properties":{"status":{"const":"error"}},"required":["status"]},"then":{"oneOf":[{"properties":{"reason":{"const":"declared_failure"}},"required":["reason"]},{"properties":{"code":{"const":"refusal"},"reason":{"enum":["policy_denied","interactive_input_required","authentication_required"]}},"required":["code","reason"]},{"properties":{"code":{"const":"malformed"},"reason":{"const":"malformed_result"}},"required":["code","reason"]}]}}]},"WorkerOutcomeWire":{"oneOf":[{"additionalProperties":false,"properties":{"artifacts":{"items":{"$ref":"#/$defs/ArtifactRef"},"type":"array"},"output":true,"status":{"const":"verified","type":"string"}},"required":["status","output","artifacts"],"type":"object"},{"additionalProperties":false,"properties":{"artifacts":{"items":{"$ref":"#/$defs/ArtifactRef"},"type":"array"},"diagnostic":true,"output":true,"signals":{"additionalProperties":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"propertyNames":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"type":"object"},"status":{"const":"verifier","type":"string"}},"required":["status","output","signals","diagnostic","artifacts"],"type":"object"},{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/WorkerErrorCode"},"reason":{"$ref":"#/$defs/WorkerFailureReason"},"status":{"const":"error","type":"string"}},"required":["status","code","reason"],"type":"object"}]},"WriteBinding":{"additionalProperties":false,"properties":{"target":{"items":{"maxLength":128,"minLength":1,"pattern":"^[A-Za-z_][A-Za-z0-9_.-]*$","type":"string"},"maxItems":64,"minItems":1,"type":"array"},"value":{"$ref":"#/$defs/NodeOutputSelector"}},"required":["value","target"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"agent_attach_closed_notification":{"$ref":"#/$defs/JsonRpcNotification8"},"agent_attach_event_notification":{"$ref":"#/$defs/JsonRpcNotification7"},"agent_attach_request":{"$ref":"#/$defs/JsonRpcRequest12"},"agent_attach_response":{"$ref":"#/$defs/JsonRpcResponse12"},"apply_request":{"$ref":"#/$defs/JsonRpcRequest3"},"apply_response":{"$ref":"#/$defs/JsonRpcResponse3"},"cancel_request_notification":{"$ref":"#/$defs/JsonRpcNotification4"},"delete_request":{"$ref":"#/$defs/JsonRpcRequest9"},"delete_response":{"$ref":"#/$defs/JsonRpcResponse9"},"event_notification":{"$ref":"#/$defs/JsonRpcNotification"},"get_request":{"$ref":"#/$defs/JsonRpcRequest4"},"get_response":{"$ref":"#/$defs/JsonRpcResponse4"},"initialize_request":{"$ref":"#/$defs/JsonRpcRequest"},"initialize_response":{"$ref":"#/$defs/JsonRpcResponse"},"log_event_notification":{"$ref":"#/$defs/JsonRpcNotification5"},"logs_closed_notification":{"$ref":"#/$defs/JsonRpcNotification6"},"logs_request":{"$ref":"#/$defs/JsonRpcRequest11"},"logs_response":{"$ref":"#/$defs/JsonRpcResponse11"},"plan_request":{"$ref":"#/$defs/JsonRpcRequest2"},"plan_response":{"$ref":"#/$defs/JsonRpcResponse2"},"resubmit_request":{"$ref":"#/$defs/JsonRpcRequest8"},"resubmit_response":{"$ref":"#/$defs/JsonRpcResponse8"},"retry_request":{"$ref":"#/$defs/JsonRpcRequest7"},"retry_response":{"$ref":"#/$defs/JsonRpcResponse7"},"stop_request":{"$ref":"#/$defs/JsonRpcRequest6"},"stop_response":{"$ref":"#/$defs/JsonRpcResponse6"},"subscription_cancel_notification":{"$ref":"#/$defs/JsonRpcNotification2"},"subscription_closed_notification":{"$ref":"#/$defs/JsonRpcNotification3"},"update_request":{"$ref":"#/$defs/JsonRpcRequest5"},"update_response":{"$ref":"#/$defs/JsonRpcResponse5"},"watch_request":{"$ref":"#/$defs/JsonRpcRequest10"},"watch_response":{"$ref":"#/$defs/JsonRpcResponse10"}},"required":["initialize_request","initialize_response","plan_request","plan_response","apply_request","apply_response","get_request","get_response","update_request","update_response","stop_request","stop_response","retry_request","retry_response","resubmit_request","resubmit_response","delete_request","delete_response","watch_request","watch_response","event_notification","subscription_cancel_notification","subscription_closed_notification","cancel_request_notification","logs_request","logs_response","log_event_notification","logs_closed_notification","agent_attach_request","agent_attach_response","agent_attach_event_notification","agent_attach_closed_notification"],"title":"ImplementedProtocolSchema","type":"object"}; diff --git a/src/cluster/generated/protocol.ts b/src/cluster/generated/protocol.ts new file mode 100644 index 00000000..0b0b72c4 --- /dev/null +++ b/src/cluster/generated/protocol.ts @@ -0,0 +1,183 @@ +// Generated by scripts/generate-cluster-types.js from the checked-in Cluster Protocol v1 artifacts. +// Do not edit this file by hand. Run npm run protocol:generate. + +export const JSON_RPC_VERSION = "2.0" as const; +export const PROTOCOL_VERSION = "openengine.cluster/v1" as const; +export const SUBSCRIPTION_QUEUE_CAPACITY = 1024 as const; +export const MAX_FRAME_BYTES = 1048576 as const; +export const CLUSTER_METHODS = ["initialize","plan","apply","update","stop","retry","resubmit","delete","get","watch","logs","agent/attach"] as const; +export type ClusterMethod = (typeof CLUSTER_METHODS)[number]; + +export const UNARY_METHODS = ["initialize","plan","apply","update","stop","retry","resubmit","delete","get"] as const; +export type UnaryClusterMethod = (typeof UNARY_METHODS)[number]; +export const SUBSCRIPTION_METHODS = ["watch","logs","agent/attach"] as const; +export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number]; +export const METHOD_RESULT_DEFINITIONS = {"initialize":"InitializeResult","plan":"PlanResult","apply":"ApplyResult","update":"UpdateResult","stop":"StopResult","retry":"RetryResult","resubmit":"ResubmitResult","delete":"DeleteResult","get":"GetResult","watch":"WatchResult","logs":"LogsResult","agent/attach":"AgentAttachResult"} as const; + +export const JSON_RPC_ERROR_CODES = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + APPLICATION_ERROR: -32000, +} as const; + +export const DOMAIN_ERROR_CODES = ["NOT_FOUND","GONE","SLOW_CONSUMER","UNSUPPORTED_PROTOCOL_VERSION","INTERNAL_ERROR"] as const; + +export type AdmissionTransition = { readonly "runId": string; readonly "seedInput": unknown; readonly "spec": GraphSpec; }; +export type AgentAttachClosedNotification = { readonly "reason": SubscriptionCloseReason; readonly "subscriptionId": string; }; +export type AgentAttachEvent = { readonly "type": "working"; } | { readonly "text": string; readonly "type": "output"; } | { readonly "type": "settled"; }; +export type AgentAttachEventNotification = AgentAttachEventNotificationWire; +export type AgentAttachEventNotificationWire = { readonly "event": AgentAttachEvent; readonly "subscriptionId": string; }; +export type AgentAttachParams = { readonly "execution": string; }; +export type AgentAttachResult = { readonly "subscriptionId": string; }; +export type ApplyParams = { readonly "dryRun"?: boolean; readonly "graph": GraphSpec; readonly "idempotencyKey"?: string; readonly "ifGeneration"?: number; readonly "input"?: unknown; }; +export type ApplyResult = { readonly "deduped": boolean; readonly "diff"?: GraphDiff | null; readonly "generation"?: number | null; readonly "phase": Phase; readonly "runId"?: string | null; }; +export type ArtifactLineage = { readonly "attempt": number; readonly "generation": number; readonly "runId": string; }; +export type ArtifactProducer = { readonly "node": string; readonly "worker": string; }; +export type ArtifactRef = { readonly "artifactId": string; readonly "byteLength": number; readonly "lineage": ArtifactLineage; readonly "mediaType": string; readonly "producer": ArtifactProducer; readonly "redaction": RedactionClass; readonly "sha256": string; readonly "typeId": string; }; +export type BackendFault = BackendFaultWire; +export type BackendFaultWire = { readonly "action": FaultAction; readonly "code": FaultCode; readonly "consequence": FaultConsequence; readonly "eventId": string; readonly "executionRef"?: string | null; readonly "retry": FaultRetryDisposition; readonly "severity": FaultSeverity; readonly "source": ReadonlyArray; readonly "summary": string; }; +export type CancelRequestParams = { readonly "id": RequestId; }; +export type ChoiceBranch = { readonly "node": GraphNode; readonly "when": Guard; }; +export type ClusterStatus = { readonly "atCursor"?: string | null; readonly "currentRunId"?: string | null; readonly "observedGeneration"?: number | null; readonly "operational"?: OperationalStatus | null; readonly "phase": Phase; }; +export type ControlSelector = { readonly "field"?: string | null; readonly "name": string; readonly "source": ControlSource; }; +export type ControlSource = "signal" | "error" | "group"; +export type DataSelector = { readonly "path": ReadonlyArray; readonly "source": "state"; } | { readonly "path": ReadonlyArray; readonly "source": "item"; }; +export type DeleteParams = { readonly "idempotencyKey": string; readonly "ifGeneration": number; readonly "ifRunId"?: string | null; }; +export type DeleteResult = { readonly "atCursor"?: string | null; readonly "deduped": boolean; readonly "deleted": boolean; readonly "generation"?: number | null; readonly "phase": Phase; readonly "runId"?: string | null; }; +export type DiagnosticPathSegment = { readonly "kind": "field"; readonly "name": string; } | { readonly "index": number; readonly "kind": "index"; } | { readonly "kind": "node"; readonly "name": string; }; +export type DiagnosticSeverity = "error" | "warning" | "info"; +export type DispatchState = "active" | "suspended" | "draining" | "force_stopping" | "stopped"; +export type DomainErrorData = { readonly "code": string; readonly "details"?: unknown; }; +export type EventNotification = { readonly "cursor": string; readonly "event": WatchEvent; readonly "runId": string; readonly "subscriptionId": string; }; +export type FaultAction = "none" | "retry" | "wait" | "escalate" | "abort"; +export type FaultCode = "unavailable" | "resource_exhausted" | "deadline_exceeded" | "permission_denied" | "failed_precondition" | "not_found" | "aborted" | "internal" | "unknown"; +export type FaultConsequence = "turn_failed" | "run_failed" | "run_degraded" | "no_observable_effect"; +export type FaultRetryDisposition = "retryable" | "retryable_after_backoff" | "not_retryable" | "indeterminate"; +export type FaultSeverity = "info" | "warning" | "error" | "critical"; +export type FaultSourceFrame = { readonly "component": string; }; +export type GetParams = { readonly "atCursor"?: string | null; }; +export type GetResult = { readonly "atCursor"?: string | null; readonly "spec"?: GraphSpec | null; readonly "status": ClusterStatus; }; +export type GraphDiagnostic = { readonly "code": GraphDiagnosticCode; readonly "message": string; readonly "path": ReadonlyArray; readonly "relatedNodes": ReadonlyArray; readonly "severity": DiagnosticSeverity; }; +export type GraphDiagnosticCode = "schema_safety" | "reachability" | "choice_exhaustiveness" | "loop_exit_satisfiability" | "missing_bound" | "write_conflict" | "ceiling_exceeded" | "cyclic_reference" | "undefined_read" | "invalid_graph_shape"; +export type GraphDiff = { readonly "added": ReadonlyArray; readonly "changed": ReadonlyArray; readonly "removed": ReadonlyArray; }; +export type GraphNode = { readonly "attempts": number; readonly "input": PayloadType; readonly "inputBindings": ReadonlyArray; readonly "kind": "step"; readonly "name": string; readonly "output": PayloadType; readonly "timeoutMs": number; readonly "worker": string; readonly "writeBindings": ReadonlyArray; } | { readonly "attempts": number; readonly "diagnostic": PayloadType; readonly "input": PayloadType; readonly "inputBindings": ReadonlyArray; readonly "kind": "verifier"; readonly "name": string; readonly "output": PayloadType; readonly "signals": { readonly [key: string]: ReadonlyArray }; readonly "timeoutMs": number; readonly "worker": string; readonly "writeBindings": ReadonlyArray; } | { readonly "children": NonEmptyVec_of_GraphNode; readonly "kind": "seq"; readonly "name": string; readonly "promotedStatePaths": ReadonlyArray>; readonly "state": PayloadType; } | ({ readonly "branches": NonEmptyVec_of_ChoiceBranch; readonly "kind": "choice"; readonly "name": string; readonly "otherwise"?: GraphNode | null; readonly "promotedStatePaths": ReadonlyArray>; readonly "state": PayloadType; }) | { readonly "branches": NonEmptyVec_of_GraphNode; readonly "join": Join; readonly "kind": "par"; readonly "name": string; readonly "promotedStatePaths": ReadonlyArray>; readonly "state": PayloadType; } | { readonly "body": GraphNode; readonly "kind": "loop"; readonly "maxIterations": number; readonly "name": string; readonly "promotedStatePaths": ReadonlyArray>; readonly "state": PayloadType; readonly "until": Guard; } | { readonly "body": GraphNode; readonly "kind": "map"; readonly "maxItems": number; readonly "name": string; readonly "over": DataSelector; readonly "promotedStatePaths": ReadonlyArray>; readonly "state": PayloadType; } | { readonly "bindings": ReadonlyArray; readonly "kind": "succeed"; readonly "name": string; readonly "output": PayloadType; } | { readonly "kind": "fail"; readonly "name": string; readonly "reason": string; }; +export type GraphProfile = "openengine.graph.full/v1" | "openengine.graph.single-worker/v1"; +export type GraphSpec = { readonly "initialInput": PayloadType; readonly "policy": PolicyBinding; readonly "profile": GraphProfile; readonly "root": GraphNode; }; +export type Guard = { readonly "kind": "in"; readonly "labels": ReadonlyArray; readonly "value": ControlSelector; } | { readonly "guards": NonEmptyVec_of_Guard; readonly "kind": "all"; } | { readonly "guards": NonEmptyVec_of_Guard; readonly "kind": "any"; } | { readonly "guard": Guard; readonly "kind": "not"; } | { readonly "count": number; readonly "kind": "k_of_n"; readonly "labels": ReadonlyArray; readonly "values": NonEmptyVec_of_ControlSelector; } | { readonly "count": number; readonly "kind": "k_of_map"; readonly "labels": ReadonlyArray; readonly "value": ControlSelector; }; +export type InitializeParams = { readonly "protocolVersion": "openengine.cluster/v1"; }; +export type InitializeResult = { readonly "capabilities": ServerCapabilities; readonly "protocolVersion": "openengine.cluster/v1"; readonly "status": ClusterStatus; }; +export type InputBinding = { readonly "target": ReadonlyArray; readonly "value": DataSelector; }; +export type Join = { readonly "kind": "all"; } | { readonly "kind": "any"; } | { readonly "count": number; readonly "kind": "quorum"; } | { readonly "kind": "first"; readonly "when": Guard; }; +export type JsonRpcError = { readonly "code": number; readonly "data"?: DomainErrorData | null; readonly "message": string; }; +export type JsonRpcErrorResponse = { readonly "error": JsonRpcError; readonly "id"?: RequestId | null; readonly "jsonrpc": string; }; +export type JsonRpcNotification = { readonly "jsonrpc": string; readonly "method": string; readonly "params": EventNotification; }; +export type JsonRpcNotification2 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": SubscriptionCancelParams; }; +export type JsonRpcNotification3 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": SubscriptionClosedNotification; }; +export type JsonRpcNotification4 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": CancelRequestParams; }; +export type JsonRpcNotification5 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": LogEventNotification; }; +export type JsonRpcNotification6 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": LogsClosedNotification; }; +export type JsonRpcNotification7 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": AgentAttachEventNotification; }; +export type JsonRpcNotification8 = { readonly "jsonrpc": string; readonly "method": string; readonly "params": AgentAttachClosedNotification; }; +export type JsonRpcRequest = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": InitializeParams; }; +export type JsonRpcRequest10 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": WatchParams; }; +export type JsonRpcRequest11 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": LogsParams; }; +export type JsonRpcRequest12 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": AgentAttachParams; }; +export type JsonRpcRequest2 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": PlanParams; }; +export type JsonRpcRequest3 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": ApplyParams; }; +export type JsonRpcRequest4 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": GetParams; }; +export type JsonRpcRequest5 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": UpdateParams; }; +export type JsonRpcRequest6 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": StopParams; }; +export type JsonRpcRequest7 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": RetryParams; }; +export type JsonRpcRequest8 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": ResubmitParams; }; +export type JsonRpcRequest9 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "method": string; readonly "params": DeleteParams; }; +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse; +export type JsonRpcResponse10 = JsonRpcSuccess10 | JsonRpcErrorResponse; +export type JsonRpcResponse11 = JsonRpcSuccess11 | JsonRpcErrorResponse; +export type JsonRpcResponse12 = JsonRpcSuccess12 | JsonRpcErrorResponse; +export type JsonRpcResponse2 = JsonRpcSuccess2 | JsonRpcErrorResponse; +export type JsonRpcResponse3 = JsonRpcSuccess3 | JsonRpcErrorResponse; +export type JsonRpcResponse4 = JsonRpcSuccess4 | JsonRpcErrorResponse; +export type JsonRpcResponse5 = JsonRpcSuccess5 | JsonRpcErrorResponse; +export type JsonRpcResponse6 = JsonRpcSuccess6 | JsonRpcErrorResponse; +export type JsonRpcResponse7 = JsonRpcSuccess7 | JsonRpcErrorResponse; +export type JsonRpcResponse8 = JsonRpcSuccess8 | JsonRpcErrorResponse; +export type JsonRpcResponse9 = JsonRpcSuccess9 | JsonRpcErrorResponse; +export type JsonRpcSuccess = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": InitializeResult; }; +export type JsonRpcSuccess10 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": WatchResult; }; +export type JsonRpcSuccess11 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": LogsResult; }; +export type JsonRpcSuccess12 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": AgentAttachResult; }; +export type JsonRpcSuccess2 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": PlanResult; }; +export type JsonRpcSuccess3 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": ApplyResult; }; +export type JsonRpcSuccess4 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": GetResult; }; +export type JsonRpcSuccess5 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": UpdateResult; }; +export type JsonRpcSuccess6 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": StopResult; }; +export type JsonRpcSuccess7 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": RetryResult; }; +export type JsonRpcSuccess8 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": ResubmitResult; }; +export type JsonRpcSuccess9 = { readonly "id": RequestId; readonly "jsonrpc": string; readonly "result": DeleteResult; }; +export type Labels = { readonly [key: string]: string }; +export type LogEventNotification = LogEventNotificationWire; +export type LogEventNotificationWire = { readonly "record": LogRecord; readonly "subscriptionId": string; }; +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"; +export type LogRecord = { readonly "level": LogLevel; readonly "message": string; readonly "target": string; }; +export type LogsClosedNotification = { readonly "reason": SubscriptionCloseReason; readonly "subscriptionId": string; }; +export type LogsParams = Record; +export type LogsResult = { readonly "subscriptionId": string; }; +export type NodeAddress = { readonly "attempt": number; readonly "node": string; }; +export type NodeOutputChannel = "out" | "signal" | "diagnostic"; +export type NodeOutputSelector = { readonly "channel": NodeOutputChannel; readonly "node": string; readonly "path": ReadonlyArray; }; +export type NonEmptyVec_of_ChoiceBranch = ReadonlyArray; +export type NonEmptyVec_of_ControlSelector = ReadonlyArray; +export type NonEmptyVec_of_FieldPath = ReadonlyArray>; +export type NonEmptyVec_of_GraphNode = ReadonlyArray; +export type NonEmptyVec_of_Guard = ReadonlyArray; +export type NonEmptyVec_of_NodeName = ReadonlyArray; +export type OperationalStatus = { readonly "dispatchState": DispatchState; readonly "inFlight": number; readonly "labels": Labels; readonly "logLevel": LogLevel; readonly "stopMode"?: StopMode | null; }; +export type PayloadType = { readonly "kind": "null"; } | { readonly "kind": "boolean"; } | { readonly "kind": "integer"; } | { readonly "kind": "number"; } | { readonly "kind": "string"; } | { readonly "fields": { readonly [key: string]: RecordField }; readonly "kind": "record"; } | { readonly "items": PayloadType; readonly "kind": "array"; } | { readonly "kind": "enum"; readonly "values": ReadonlyArray; }; +export type Phase = "empty" | "admitting" | "running" | "finished" | "deleting"; +export type PlanParams = { readonly "graph": GraphSpec; }; +export type PlanResult = { readonly "bounds"?: StructuralBounds | null; readonly "diagnostics": ReadonlyArray; readonly "ok": boolean; }; +export type PolicyBinding = { readonly "default": PolicyDefault; readonly "policy": string; }; +export type PolicyDefault = "deny"; +export type RecordField = { readonly "required": boolean; readonly "type": PayloadType; }; +export type RedactionClass = "public" | "internal" | "confidential" | "restricted"; +export type RequestId = string | number; +export type ResubmitParams = { readonly "idempotencyKey": string; readonly "ifGeneration": number; readonly "ifRunId": string; readonly "replacementInput"?: unknown; }; +export type ResubmitResult = { readonly "atCursor": string; readonly "deduped": boolean; readonly "generation": number; readonly "operational": OperationalStatus; readonly "phase": Phase; readonly "priorRunId": string; readonly "runId": string; }; +export type RetryParams = { readonly "idempotencyKey": string; readonly "ifGeneration": number; }; +export type RetryResult = { readonly "atCursor": string; readonly "deduped": boolean; readonly "generation": number; readonly "operational": OperationalStatus; readonly "phase": Phase; readonly "retriedTurnId": string; readonly "retryTurnId": string; readonly "runId": string; }; +export type ServerCapabilities = { readonly "agentAttach"?: boolean; readonly "graphProfiles"?: ReadonlyArray; readonly "logs"?: boolean; }; +export type StopMode = "drain" | "force"; +export type StopParams = { readonly "idempotencyKey": string; readonly "ifGeneration": number; readonly "mode": StopMode; }; +export type StopResult = { readonly "acceptedMode": StopMode; readonly "atCursor": string; readonly "deduped": boolean; readonly "effectiveMode": StopMode; readonly "generation": number; readonly "operational": OperationalStatus; readonly "phase": Phase; readonly "runId": string; }; +export type StructuralBounds = { readonly "attemptsPerNode": { readonly [key: string]: number }; readonly "maxNodeExecutions": number; readonly "peakConcurrency": number; readonly "termination": TerminationWitness; }; +export type SubscriptionCancelParams = { readonly "subscriptionId": string; }; +export type SubscriptionCloseReason = "done" | "SLOW_CONSUMER"; +export type SubscriptionClosedNotification = { readonly "lastDeliveredCursor"?: string | null; readonly "reason": SubscriptionCloseReason; readonly "subscriptionId": string; }; +export type TerminationWitness = { readonly "kind": "acyclic"; readonly "order": NonEmptyVec_of_NodeName; } | { readonly "kind": "bounded"; readonly "maxIterations": number; readonly "ranking": NonEmptyVec_of_FieldPath; }; +export type UpdateParams = ({ readonly "idempotencyKey": string; readonly "ifGeneration": number; readonly "labels"?: Labels; readonly "logLevel"?: LogLevel; readonly "suspended"?: boolean; }) & ({ readonly "labels": unknown; } | { readonly "logLevel": unknown; } | { readonly "suspended": unknown; }); +export type UpdateResult = { readonly "atCursor": string; readonly "deduped": boolean; readonly "generation": number; readonly "operational": OperationalStatus; readonly "phase": Phase; readonly "runId": string; }; +export type WatchEvent = ({ readonly "admission"?: AdmissionTransition | null; readonly "status": ClusterStatus; readonly "type": "phase"; }) | { readonly "input": unknown; readonly "node": NodeAddress; readonly "type": "node_begin"; } | { readonly "node": NodeAddress; readonly "outcome": WorkerOutcome; readonly "type": "node_end"; } | { readonly "type": "bookmark"; } | { readonly "fault": BackendFault; readonly "type": "fault"; } | ({ readonly "final_status": ClusterStatus; readonly "stop_mode"?: StopMode | null; readonly "type": "finished"; }); +export type WatchParams = { readonly "fromCursor"?: string | null; readonly "runId"?: string | null; }; +export type WatchResult = { readonly "atCursor"?: string | null; readonly "runId"?: string | null; readonly "subscriptionId": string; }; +export type WorkerErrorCode = "timeout" | "crash" | "malformed" | "refusal"; +export type WorkerFailureReason = "declared_failure" | "policy_denied" | "interactive_input_required" | "authentication_required" | "malformed_result"; +export type WorkerOutcome = WorkerOutcomeWire & unknown; +export type WorkerOutcomeWire = { readonly "artifacts": ReadonlyArray; readonly "output": unknown; readonly "status": "verified"; } | { readonly "artifacts": ReadonlyArray; readonly "diagnostic": unknown; readonly "output": unknown; readonly "signals": { readonly [key: string]: string }; readonly "status": "verifier"; } | { readonly "code": WorkerErrorCode; readonly "reason": WorkerFailureReason; readonly "status": "error"; }; +export type WriteBinding = { readonly "target": ReadonlyArray; readonly "value": NodeOutputSelector; }; + +export interface ClusterMethodParams { + readonly initialize: InitializeParams; readonly plan: PlanParams; readonly apply: ApplyParams; + readonly update: UpdateParams; readonly stop: StopParams; readonly retry: RetryParams; + readonly resubmit: ResubmitParams; readonly delete: DeleteParams; readonly get: GetParams; + readonly watch: WatchParams; readonly logs: LogsParams; readonly 'agent/attach': AgentAttachParams; +} + +export interface ClusterMethodResults { + readonly initialize: InitializeResult; readonly plan: PlanResult; readonly apply: ApplyResult; + readonly update: UpdateResult; readonly stop: StopResult; readonly retry: RetryResult; + readonly resubmit: ResubmitResult; readonly delete: DeleteResult; readonly get: GetResult; + readonly watch: WatchResult; readonly logs: LogsResult; readonly 'agent/attach': AgentAttachResult; +} diff --git a/src/cluster/index.ts b/src/cluster/index.ts new file mode 100644 index 00000000..a86f02d3 --- /dev/null +++ b/src/cluster/index.ts @@ -0,0 +1,38 @@ +export * from './generated/protocol.js'; +export { SUBSCRIPTION_QUEUE_MAX_BYTES } from './queue.js'; +export { + ClusterConfigError, + ClusterError, + ClusterInternalError, + ClusterProtocolError, + ClusterRpcError, + ClusterStateError, + ClusterTimeoutError, + ClusterTransportError, +} from './errors.js'; +export { CONNECTION_TRANSITIONS, PROTOCOL_DIAGNOSTIC_CAPACITY, Connection } from './connection.js'; +export type { + CallOptions, + ConnectionState, +} from './connection.js'; +export type { WebSocketLike } from './socket.js'; +export { + AgentAttachSubscriptionStream, + LogsSubscriptionStream, + WatchSubscriptionStream, +} from './subscriptions.js'; +export type { + Subscription, + SubscriptionClosedItem, + SubscriptionItem, + WatchSubscriptionItem, + WatchSubscriptionClosedItem, +} from './subscriptions.js'; +export { ClusterClient, connect } from './client.js'; +export type { + AgentAttachSubscription, + CoherentWatchSubscription, + ConnectOptions, + LogsSubscription, + WatchSubscription, +} from './client.js'; diff --git a/src/cluster/queue.ts b/src/cluster/queue.ts new file mode 100644 index 00000000..294b0fff --- /dev/null +++ b/src/cluster/queue.ts @@ -0,0 +1,84 @@ +import { SUBSCRIPTION_QUEUE_CAPACITY } from './generated/protocol.js'; +import { ClusterInternalError } from './errors.js'; + +export const SUBSCRIPTION_QUEUE_MAX_BYTES = 8 * 1024 * 1024; + +type Deferred = { + readonly promise: Promise; + readonly resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((onResolve) => { resolve = onResolve; }); + return { promise, resolve }; +} + +export type QueueValue = + | { readonly done: false; readonly value: T } + | { readonly done: true }; + +export class BoundedQueue { + readonly #items: Array<{ readonly value: T; readonly bytes: number }> = []; + readonly #waiters: Array>> = []; + #bytes = 0; + #producerClosed = false; + + get retainedCount(): number { return this.#items.length; } + + push(value: T, bytes: number): 'buffered' | 'delivered' | 'overflow' | 'closed' { + if (this.#producerClosed) return 'closed'; + const waiter = this.#waiters.shift(); + if (waiter) { + waiter.resolve({ done: false, value }); + this.#assertInvariant(); + return 'delivered'; + } + if ( + this.#items.length >= SUBSCRIPTION_QUEUE_CAPACITY || + this.#bytes + bytes > SUBSCRIPTION_QUEUE_MAX_BYTES + ) return 'overflow'; + this.#items.push({ value, bytes }); + this.#bytes += bytes; + this.#assertInvariant(); + return 'buffered'; + } + + recv(): Promise> { + const item = this.#items.shift(); + if (item) { + this.#bytes -= item.bytes; + this.#assertInvariant(); + return Promise.resolve({ done: false, value: item.value }); + } + if (this.#producerClosed) return Promise.resolve({ done: true }); + const waiter = deferred>(); + this.#waiters.push(waiter); + this.#assertInvariant(); + return waiter.promise; + } + + endRetainingBuffer(): void { + if (this.#producerClosed) return; + this.#producerClosed = true; + while (this.#waiters.length > 0) this.#waiters.shift()!.resolve({ done: true }); + this.#assertInvariant(); + } + + closeAndDiscard(): void { + this.#producerClosed = true; + this.#items.length = 0; + this.#bytes = 0; + while (this.#waiters.length > 0) this.#waiters.shift()!.resolve({ done: true }); + this.#assertInvariant(); + } + + #assertInvariant(): void { + if (this.#items.length > 0 && this.#waiters.length > 0) { + throw new ClusterInternalError( + 'queue contains both buffered items and waiters', + 'QUEUE_INVARIANT', + ); + } + } +} diff --git a/src/cluster/socket.ts b/src/cluster/socket.ts new file mode 100644 index 00000000..d8a7ea6a --- /dev/null +++ b/src/cluster/socket.ts @@ -0,0 +1,31 @@ +import { ClusterConfigError } from './errors.js'; + +export interface WebSocketLike { + readonly readyState: number; + send(data: string, callback?: (error?: Error) => void): void | Promise; + close(code?: number, reason?: string): void | Promise; + addEventListener?(type: string, listener: (...args: unknown[]) => void): void; + removeEventListener?(type: string, listener: (...args: unknown[]) => void): void; + on?(type: string, listener: (...args: unknown[]) => void): void; + off?(type: string, listener: (...args: unknown[]) => void): void; + removeListener?(type: string, listener: (...args: unknown[]) => void): void; +} + +export function addSocketListener( + socket: WebSocketLike, + type: string, + listener: (...args: unknown[]) => void, +): () => void { + if (socket.addEventListener) { + socket.addEventListener(type, listener); + return () => socket.removeEventListener?.(type, listener); + } + if (socket.on) { + socket.on(type, listener); + return () => (socket.off ?? socket.removeListener)?.call(socket, type, listener); + } + throw new ClusterConfigError( + 'WebSocket implementation must support event listeners', + 'INVALID_WEBSOCKET', + ); +} diff --git a/src/cluster/subscriptions.ts b/src/cluster/subscriptions.ts new file mode 100644 index 00000000..1c5f72e2 --- /dev/null +++ b/src/cluster/subscriptions.ts @@ -0,0 +1,256 @@ +import type { AgentAttachEvent, LogRecord, SubscriptionCloseReason, WatchEvent, WatchParams, WatchResult } from './generated/protocol.js'; +import { ClusterProtocolError, ClusterStateError } from './errors.js'; +import { Connection } from './connection.js'; +import type { SubscriptionRegistration } from './connection.js'; +import { isRecord } from './frames.js'; +import type { FrameRecord } from './frames.js'; +import { assertDefinition } from './validators.js'; + +export type SubscriptionClosedItem = { + readonly type: 'closed'; + readonly reason: SubscriptionCloseReason; +}; +export type WatchSubscriptionClosedItem = SubscriptionClosedItem & { + readonly lastDeliveredCursor?: string | null; +}; +export type SubscriptionItem = { readonly type: 'event'; readonly event: T } | SubscriptionClosedItem; +export type WatchSubscriptionItem = + | { readonly type: 'event'; readonly runId: string; readonly cursor: string; readonly event: WatchEvent } + | WatchSubscriptionClosedItem; +export interface Subscription extends AsyncIterator>, AsyncIterable> { + readonly subscriptionId: string; + readonly retainedCount: number; + cancel(): Promise; +} + +type FrameParser = (frame: FrameRecord) => SubscriptionItem; +class SubscriptionStream implements Subscription { + #done = false; + constructor( + protected readonly connection: Connection, + protected readonly registration: SubscriptionRegistration, + private readonly parse: FrameParser, + ) {} + get subscriptionId(): string { return this.registration.id; } + get retainedCount(): number { return this.registration.queue.retainedCount; } + [Symbol.asyncIterator](): this { return this; } + async next(): Promise>> { + if (this.#done) return { done: true, value: undefined }; + const queued = await this.registration.queue.recv(); + if (queued.done) { + if (this.registration.overflowed) { + this.registration.overflowed = false; this.#done = true; + return { done: false, value: { type: 'closed', reason: 'SLOW_CONSUMER' } }; + } + this.#done = true; return { done: true, value: undefined }; + } + try { + const value = this.parse(queued.value); + if (value.type === 'closed') this.#finishWithoutCancel(); + return { done: false, value }; + } catch (cause) { await this.#terminate(true); throw cause; } + } + async return(): Promise>> { + await this.#terminate(true); return { done: true, value: undefined }; + } + async throw(error?: unknown): Promise>> { + await this.#terminate(true); throw error; + } + cancel(): Promise { return this.#terminate(true); } + async #terminate(sendCancel: boolean): Promise { + if (this.#done) return; + this.#done = true; + this.connection.unregisterSubscription(this.registration.id, this.registration); + this.registration.queue.closeAndDiscard(); + if (sendCancel) { + try { await this.connection.cancelSubscription(this.registration); } + catch (error) { this.connection.recordDiagnostic(error); } + } + } + #finishWithoutCancel(): void { + if (this.#done) return; + this.#done = true; + this.connection.unregisterSubscription(this.registration.id, this.registration); + this.registration.queue.closeAndDiscard(); + } +} + +function parseCursorlessClosed(params: FrameRecord, definition: string): SubscriptionClosedItem { + assertDefinition(definition, params); + return { type: 'closed', reason: params.reason as SubscriptionCloseReason }; +} +function parseWatchClosed(params: FrameRecord): WatchSubscriptionClosedItem { + assertDefinition('SubscriptionClosedNotification', params); + const cursor = params.lastDeliveredCursor as string | null | undefined; + return { + type: 'closed', + reason: params.reason as SubscriptionCloseReason, + ...(cursor === undefined ? {} : { lastDeliveredCursor: cursor }), + }; +} +function subscriptionParser( + field: string, + eventDefinition: string, + closedDefinition: string, +): FrameParser { + return (frame) => { + if (!isRecord(frame.params)) { + throw new ClusterProtocolError('notification params are missing', 'INVALID_SUBSCRIPTION_EVENT'); + } + if (frame.method === 'subscription/closed') { + return parseCursorlessClosed(frame.params, closedDefinition); + } + if (frame.method !== 'event') { + throw new ClusterProtocolError('malformed subscription event', 'INVALID_SUBSCRIPTION_EVENT'); + } + assertDefinition(eventDefinition, frame.params); + return { type: 'event', event: frame.params[field] as T }; + }; +} + +export class LogsSubscriptionStream extends SubscriptionStream { + constructor(connection: Connection, registration: SubscriptionRegistration) { + super(connection, registration, subscriptionParser('record', 'LogEventNotification', 'LogsClosedNotification')); + } +} +export class AgentAttachSubscriptionStream extends SubscriptionStream { + constructor(connection: Connection, registration: SubscriptionRegistration) { + super(connection, registration, subscriptionParser('event', 'AgentAttachEventNotification', 'AgentAttachClosedNotification')); + } +} + +type WatchStreamInit = { + readonly connection: Connection; + readonly registration: SubscriptionRegistration; + readonly result: WatchResult; + readonly params: WatchParams; + readonly lastSeenRunId?: string; + readonly lastSeenCursor?: string; +}; +export class WatchSubscriptionStream implements AsyncIterator, AsyncIterable { + readonly #connection: Connection; + readonly #registration: SubscriptionRegistration; + #lastSeenRunId: string | undefined; + #lastSeenCursor: string | undefined; + #lastDelivered: string | null | undefined; + #runId: string | null | undefined; + #done = false; + #reconnectConsumed = false; + #readBarrier: Promise = Promise.resolve(); + constructor(init: WatchStreamInit) { + this.#connection = init.connection; + this.#registration = init.registration; + this.#lastSeenRunId = init.lastSeenRunId; + this.#lastSeenCursor = init.lastSeenCursor; + this.#lastDelivered = init.params.fromCursor; + this.#runId = init.result.runId ?? init.params.runId; + } + get subscriptionId(): string { return this.#registration.id; } + get retainedCount(): number { return this.#registration.queue.retainedCount; } + get lastDeliveredCursor(): string | null | undefined { return this.#lastDelivered; } + [Symbol.asyncIterator](): this { return this; } + next(): Promise> { + const read = this.#readBarrier.then(() => this.#nextLogical()); + this.#readBarrier = read.then(() => undefined, () => undefined); + return read; + } + async #nextLogical(): Promise> { + while (!this.#done) { + const queued = await this.#registration.queue.recv(); + if (queued.done) return this.#finishQueue(); + const item = await this.#decode(queued.value); + if (item) return { done: false, value: item }; + } + return { done: true, value: undefined }; + } + #finishQueue(): IteratorResult { + this.#done = true; + if (!this.#registration.overflowed) return { done: true, value: undefined }; + this.#registration.overflowed = false; + const cursor = this.#lastDelivered; + return { + done: false, + value: { type: 'closed', reason: 'SLOW_CONSUMER', ...(cursor === undefined ? {} : { lastDeliveredCursor: cursor }) }, + }; + } + async #decode(frame: FrameRecord): Promise { + if (!isRecord(frame.params)) return this.#invalid('notification params are missing'); + if (frame.method === 'subscription/closed') { + const closed = parseWatchClosed(frame.params); + if (closed.lastDeliveredCursor !== undefined) this.#lastDelivered = closed.lastDeliveredCursor; + this.#finishWithoutCancel(); return closed; + } + if (frame.method !== 'event') return this.#invalid('malformed watch event'); + try { assertDefinition('EventNotification', frame.params); } + catch { return this.#invalid('malformed watch event'); } + const { runId, cursor, event } = frame.params as { + readonly runId: string; readonly cursor: string; readonly event: WatchEvent; + }; + if (this.#lastSeenRunId === runId && this.#lastSeenCursor === cursor) return undefined; + this.#lastSeenRunId = runId; this.#lastSeenCursor = cursor; + this.#runId = runId; this.#lastDelivered = cursor; + return { type: 'event', runId, cursor, event }; + } + async return(): Promise> { + await this.#terminate(true); return { done: true, value: undefined }; + } + async throw(error?: unknown): Promise> { + await this.#terminate(true); throw error; + } + cancel(): Promise { return this.#terminate(true); } + reconnect(freshConnection: Connection): Promise { + if (this.#reconnectConsumed) { + throw new ClusterStateError('watch stream reconnect is one-shot', 'RECONNECT_CONSUMED'); + } + this.#reconnectConsumed = true; return this.#reconnectOn(freshConnection); + } + async #reconnectOn(freshConnection: Connection): Promise { + await this.#terminate(true); + try { + const runId = this.#runId; const fromCursor = this.#lastDelivered; + const params: WatchParams = { + ...(runId === undefined ? {} : { runId }), + ...(fromCursor === undefined ? {} : { fromCursor }), + }; + const established = await freshConnection.openSubscription('watch', params); + return { + result: established.result, + stream: new WatchSubscriptionStream({ + connection: freshConnection, + registration: established.registration, + result: established.result, + params, + ...(this.#lastSeenRunId === undefined ? {} : { lastSeenRunId: this.#lastSeenRunId }), + ...(this.#lastSeenCursor === undefined ? {} : { lastSeenCursor: this.#lastSeenCursor }), + }), + }; + } catch (error) { await freshConnection.close(); throw error; } + } + async #terminate(sendCancel: boolean): Promise { + if (this.#done) return; + this.#done = true; + this.#connection.unregisterSubscription(this.#registration.id, this.#registration); + this.#registration.queue.closeAndDiscard(); + if (sendCancel) { + try { await this.#connection.cancelSubscription(this.#registration); } + catch (error) { this.#connection.recordDiagnostic(error); } + } + } + #finishWithoutCancel(): void { + if (this.#done) return; + this.#done = true; + this.#connection.unregisterSubscription(this.#registration.id, this.#registration); + this.#registration.queue.closeAndDiscard(); + } + async #invalid(message: string): Promise { + await this.#terminate(true); + throw new ClusterProtocolError(message, 'INVALID_SUBSCRIPTION_EVENT'); + } +} + +Object.defineProperty(SubscriptionStream.prototype, Symbol.asyncDispose, { + configurable: true, value: SubscriptionStream.prototype.cancel, +}); +Object.defineProperty(WatchSubscriptionStream.prototype, Symbol.asyncDispose, { + configurable: true, value: WatchSubscriptionStream.prototype.cancel, +}); diff --git a/src/cluster/validators.ts b/src/cluster/validators.ts new file mode 100644 index 00000000..6c3aacd7 --- /dev/null +++ b/src/cluster/validators.ts @@ -0,0 +1,56 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import type { ValidateFunction } from 'ajv'; +import { + METHOD_RESULT_DEFINITIONS, + PROTOCOL_VERSION, +} from './generated/protocol.js'; +import type { ClusterMethod } from './generated/protocol.js'; +import { CLUSTER_PROTOCOL_SCHEMA } from './generated/protocol-schema.js'; +import { ClusterProtocolError } from './errors.js'; + +const ajv = new Ajv2020({ + allErrors: true, + strict: false, + validateFormats: false, +}); +ajv.addSchema(CLUSTER_PROTOCOL_SCHEMA as object, 'openengine-cluster-v1'); +const validators = new Map(); + +function validatorFor(definition: string): ValidateFunction { + const cached = validators.get(definition); + if (cached) return cached; + const validator = ajv.compile({ + $ref: `openengine-cluster-v1#/$defs/${definition}`, + }); + validators.set(definition, validator); + return validator; +} + +export function assertDefinition(definition: string, value: unknown): void { + const validate = validatorFor(definition); + if (validate(value)) return; + const details = (validate.errors ?? []).map((error) => + `${error.instancePath || '/'} ${error.message ?? 'is invalid'}` + ).join('; '); + throw new ClusterProtocolError( + `${definition} validation failed: ${details}`, + 'INVALID_RESPONSE', + ); +} + +export function assertMethodResult(method: ClusterMethod, value: unknown): void { + try { + assertDefinition(METHOD_RESULT_DEFINITIONS[method], value); + } catch (error) { + const receivedVersion = method === 'initialize' && value !== null && typeof value === 'object' + ? (value as Readonly>).protocolVersion + : undefined; + if (typeof receivedVersion === 'string' && receivedVersion !== PROTOCOL_VERSION) { + throw new ClusterProtocolError( + `unsupported protocol version ${receivedVersion}`, + 'UNSUPPORTED_PROTOCOL_VERSION', + ); + } + throw error; + } +} diff --git a/src/cluster/ws.d.ts b/src/cluster/ws.d.ts new file mode 100644 index 00000000..f124659e --- /dev/null +++ b/src/cluster/ws.d.ts @@ -0,0 +1,7 @@ +declare module 'ws' { + const WebSocket: new ( + url: string, + protocols?: string | readonly string[], + ) => import('./index.js').WebSocketLike; + export default WebSocket; +} diff --git a/tests/cluster/architecture.test.js b/tests/cluster/architecture.test.js new file mode 100644 index 00000000..a047f70d --- /dev/null +++ b/tests/cluster/architecture.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { contentsBelow } = require('./harness'); + +const root = path.resolve(__dirname, '../..'); +const sourceRoot = path.join(root, 'src/cluster'); +const outputRoot = path.join(root, 'lib/cluster'); + +test('cluster source and every emitted artifact are isolated from provider helpers', () => { + const forbidden = ['agent', 'cli', 'provider'].join('-'); + for (const { file, content } of [...contentsBelow(sourceRoot), ...contentsBelow(outputRoot)]) { + assert.equal(content.includes(forbidden), false, file); + } +}); + +test('cluster source has no imports from unrelated product internals', () => { + for (const { file, content } of contentsBelow(sourceRoot)) { + for (const match of content.matchAll(/(?:from\s+|import\()(['"])([^'"]+)\1/g)) { + const specifier = match[2]; + if (specifier === 'ws' || specifier === 'ajv' || specifier.startsWith('ajv/')) continue; + assert.ok(specifier.startsWith('./') || specifier.startsWith('../'), `${file}: ${specifier}`); + const target = path.resolve(path.dirname(file), specifier); + assert.ok(target.startsWith(sourceRoot + path.sep), `${file}: ${specifier}`); + } + } +}); + +test('request allocation and reconnect ownership invariants are mechanically singular', () => { + const connection = fs.readFileSync(path.join(sourceRoot, 'connection.ts'), 'utf8'); + const subscriptions = fs.readFileSync(path.join(sourceRoot, 'subscriptions.ts'), 'utf8'); + assert.equal([...connection.matchAll(/this\.#allocateId\(/g)].length, 1); + assert.equal([...connection.matchAll(/#sequence\+\+/g)].length, 1); + const nextBodies = subscriptions.match(/async next\(\)[\s\S]*?\n[ ]{2}\}/g) ?? []; + for (const body of nextBodies) assert.equal(body.includes('.reconnect('), false); + assert.doesNotMatch(subscriptions, /\b(?:new\s+Set|Set<)/); + const runtime = contentsBelow(sourceRoot) + .map(({ content }) => content) + .join('\n'); + assert.equal(/this\.(?:socket|transport)\s*=/.test(runtime), false); +}); + +test('cluster regressions contain no wall-clock race waits', () => { + const forbidden = [ + String.raw`set` + String.raw`Timeout\s*\(`, + String.raw`sle` + String.raw`ep\s*\(`, + ]; + const expression = new RegExp(forbidden.join('|')); + for (const { file, content } of contentsBelow(path.join(root, 'tests/cluster'))) { + assert.equal(expression.test(content), false, file); + } +}); diff --git a/tests/cluster/client.test.js b/tests/cluster/client.test.js new file mode 100644 index 00000000..e6d86526 --- /dev/null +++ b/tests/cluster/client.test.js @@ -0,0 +1,397 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + AgentAttachSubscriptionStream, + ClusterClient, + ClusterRpcError, + Connection, + connect, + LogsSubscriptionStream, + MAX_FRAME_BYTES, + SUBSCRIPTION_QUEUE_CAPACITY, +} = require('../../lib/cluster/index.cjs'); +const { FakeWebSocket, assertClean, deferred, settle } = require('./harness'); + +async function establish(client, socket, method, result) { + const pending = + method === 'agent/attach' ? client.agentAttach({ execution: 'exec-1' }) : client[method](); + await settle(); + const request = socket.request(method); + socket.respond(request.id, result); + return pending; +} + +test('one connection owns collision-free ids across clients and subscriptions', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const first = new ClusterClient(connection); + const second = new ClusterClient(connection); + const calls = [first.get(), second.get(), first.get(), second.logs()]; + await settle(); + const requests = socket.sent.filter((frame) => 'id' in frame); + assert.equal(new Set(requests.map(({ id }) => id)).size, 4); + for (const request of requests) + socket.respond( + request.id, + request.method === 'logs' ? { subscriptionId: 'logs-1' } : { status: { phase: 'empty' } } + ); + const [, , , logs] = await Promise.all(calls); + await logs.stream.cancel(); + await connection.close(); + assertClean(connection); +}); + +test('failed sends remove only their exact pending entry', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const client = new ClusterClient(connection); + const first = client.get(); + await settle(); + const firstRequest = socket.request('get'); + socket.sendFailure = new Error('closed'); + await assert.rejects(client.get(), { code: 'SEND_FAILED' }); + assert.equal(connection.pendingSize, 1); + socket.sendFailure = undefined; + socket.respond(firstRequest.id, { status: { phase: 'empty' } }); + await first; + for (let index = 0; index < 100; index += 1) { + socket.sendFailure = + index % 2 === 0 ? new Error('closed') : Promise.reject(new Error('closed')); + await assert.rejects(client.get(), { code: 'SEND_FAILED' }); + assert.equal(connection.pendingSize, 0); + } + socket.sendFailure = undefined; + await connection.close(); +}); + +test('bounded queue retains the pre-overflow FIFO then closes once', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const { stream } = await establish(new ClusterClient(connection), socket, 'logs', { + subscriptionId: 'logs-1', + }); + assert.ok(stream instanceof LogsSubscriptionStream); + for (let index = 0; index < SUBSCRIPTION_QUEUE_CAPACITY + 1; index += 1) + socket.notify('event', { + subscriptionId: 'logs-1', + record: { level: 'info', target: 'test', message: String(index) }, + }); + await settle(); + assert.equal(connection.subscriptionCount, 0); + assert.equal(stream.retainedCount, SUBSCRIPTION_QUEUE_CAPACITY); + assert.deepEqual(socket.notifications('subscription/cancel').at(-1).params, { + subscriptionId: 'logs-1', + }); + for (let index = 0; index < SUBSCRIPTION_QUEUE_CAPACITY; index += 1) + assert.equal((await stream.next()).value.event.message, String(index)); + assert.deepEqual(await stream.next(), { + done: false, + value: { type: 'closed', reason: 'SLOW_CONSUMER' }, + }); + assert.deepEqual(await stream.next(), { done: true, value: undefined }); + await connection.close(); +}); + +test('queue byte bound and frame byte bound prevent peer-controlled growth', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const { stream } = await establish(new ClusterClient(connection), socket, 'logs', { + subscriptionId: 'logs-bytes', + }); + const message = 'x'.repeat(900_000); + for (let index = 0; index < 10; index += 1) { + socket.notify('event', { + subscriptionId: 'logs-bytes', + record: { level: 'info', target: 'test', message }, + }); + } + assert.equal(connection.subscriptionCount, 0); + assert.ok(stream.retainedCount < SUBSCRIPTION_QUEUE_CAPACITY); + await stream.cancel(); + socket.emit('message', { data: 'x'.repeat(1_048_577) }); + assert.equal(connection.state, 'OPEN'); + assert.equal(connection.protocolDiagnostics.at(-1).code, 'INVALID_PEER_FRAME'); + await connection.close(); +}); + +test('oversized binary and multibyte ingress is rejected before routing', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const pending = new ClusterClient(connection).get(); + await settle(); + const request = socket.request('get'); + const oversizedFrames = [ + new Uint8Array(MAX_FRAME_BYTES + 1), + 'é'.repeat(Math.floor(MAX_FRAME_BYTES / 2) + 1), + ]; + for (const data of oversizedFrames) { + const diagnosticsBefore = connection.protocolDiagnostics.length; + socket.emit('message', { data }); + assert.equal(connection.state, 'OPEN'); + assert.equal(connection.pendingSize, 1); + assert.equal(connection.protocolDiagnostics.length, diagnosticsBefore + 1); + assert.match(connection.protocolDiagnostics.at(-1).message, /frame exceeds/); + } + socket.respond(request.id, { status: { phase: 'empty' } }); + await pending; + await connection.close(); +}); + +test('a terminal overflow frame unregisters without sending cancel', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const { stream } = await establish(new ClusterClient(connection), socket, 'agent/attach', { + subscriptionId: 'attach-1', + }); + assert.ok(stream instanceof AgentAttachSubscriptionStream); + for (let index = 0; index < SUBSCRIPTION_QUEUE_CAPACITY; index += 1) + socket.notify('event', { subscriptionId: 'attach-1', event: { type: 'working' } }); + socket.notify('subscription/closed', { subscriptionId: 'attach-1', reason: 'done' }); + await settle(); + assert.equal(socket.notifications('subscription/cancel').length, 0); + assert.equal(connection.subscriptionCount, 0); + await stream.cancel(); + await connection.close(); +}); + +test('multiple pending next calls are delivered FIFO and all settle on closure', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const { stream } = await establish(new ClusterClient(connection), socket, 'watch', { + subscriptionId: 'watch-1', + runId: 'run-1', + }); + const order = []; + const pending = [stream.next(), stream.next(), stream.next()].map((promise, index) => + promise.then((value) => { + order.push(index); + return value; + }) + ); + socket.notify('event', { + subscriptionId: 'watch-1', + runId: 'run-1', + cursor: '1', + event: { type: 'bookmark' }, + }); + socket.notify('event', { + subscriptionId: 'watch-1', + runId: 'run-1', + cursor: '2', + event: { type: 'bookmark' }, + }); + socket.notify('subscription/closed', { + subscriptionId: 'watch-1', + reason: 'done', + lastDeliveredCursor: '2', + }); + const values = await Promise.all(pending); + assert.deepEqual(order, [0, 1, 2]); + assert.deepEqual( + values.map(({ value }) => value.type), + ['event', 'event', 'closed'] + ); + assert.deepEqual(await Promise.all([stream.next(), stream.next()]), [ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]); + await connection.close(); +}); + +test('abort cancellation is exact and late subscription successes are reaped', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const controller = new AbortController(); + const pending = new ClusterClient(connection).watch({}, { signal: controller.signal }); + await settle(); + const request = socket.request('watch'); + controller.abort(); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(socket.notifications('$/cancelRequest').length, 1); + socket.respond(request.id, { subscriptionId: 'late-1' }); + await settle(); + assert.deepEqual( + socket.notifications('subscription/cancel').map(({ params }) => params), + [{ subscriptionId: 'late-1' }] + ); + assert.equal(connection.subscriptionCount, 0); + await connection.close(); +}); + +test('iterator return cancels exactly once and clears retained frames', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const { stream } = await establish(new ClusterClient(connection), socket, 'logs', { + subscriptionId: 'logs-1', + }); + socket.notify('event', { + subscriptionId: 'logs-1', + record: { level: 'info', target: 'test', message: 'buffered' }, + }); + assert.equal(stream.retainedCount, 1); + await stream.return(); + await stream.return(); + assert.equal(stream.retainedCount, 0); + assert.equal(socket.notifications('subscription/cancel').length, 1); + await connection.close(); +}); + +test('watch reconnect consumes once, uses only the fresh connection, and preserves pair dedup', async () => { + const oldSocket = new FakeWebSocket(); + const oldConnection = new Connection(oldSocket); + const { stream } = await establish(new ClusterClient(oldConnection), oldSocket, 'watch', { + subscriptionId: 'old-watch', + runId: 'run-1', + }); + const first = stream.next(); + oldSocket.notify('event', { + subscriptionId: 'old-watch', + runId: 'run-1', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + await first; + await oldConnection.close(); + const freshSocket = new FakeWebSocket(); + const freshConnection = new Connection(freshSocket); + const reconnected = stream.reconnect(freshConnection); + const rejectedFresh = new Connection(new FakeWebSocket()); + assert.throws(() => stream.reconnect(rejectedFresh), { code: 'RECONNECT_CONSUMED' }); + await rejectedFresh.close(); + await settle(); + const request = freshSocket.request('watch'); + assert.deepEqual(request.params, { runId: 'run-1', fromCursor: 'cursor-1' }); + assert.equal(oldSocket.sent.filter((frame) => frame.method === 'watch').length, 1); + freshSocket.respond(request.id, { subscriptionId: 'fresh-watch', runId: 'run-1' }); + const replacement = await reconnected; + const next = replacement.stream.next(); + freshSocket.notify('event', { + subscriptionId: 'fresh-watch', + runId: 'run-1', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + freshSocket.notify('event', { + subscriptionId: 'fresh-watch', + runId: 'run-2', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + assert.deepEqual((await next).value, { + type: 'event', + runId: 'run-2', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + await replacement.stream.cancel(); + await oldConnection.close(); + await freshConnection.close(); +}); + +test('a failed reconnect closes the supplied fresh connection', async () => { + const oldSocket = new FakeWebSocket(); + const oldConnection = new Connection(oldSocket); + const { stream } = await establish(new ClusterClient(oldConnection), oldSocket, 'watch', { + subscriptionId: 'old-watch', + runId: 'run-1', + }); + const freshSocket = new FakeWebSocket(); + const freshConnection = new Connection(freshSocket); + const reconnect = stream.reconnect(freshConnection); + await settle(); + const request = freshSocket.request('watch'); + freshSocket.error(request.id, -32000, 'unavailable', 'GONE'); + await assert.rejects(reconnect, { code: 'GONE' }); + assert.equal(freshConnection.state, 'CLOSED'); + assert.equal(freshSocket.closeCalls, 1); + await oldConnection.close(); +}); + +test('cold start performs coherent get then watch on one connection', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const pending = new ClusterClient(connection).watchColdStart({ runId: 'run-1' }); + await settle(); + const get = socket.request('get'); + socket.respond(get.id, { status: { phase: 'running' }, atCursor: 'snapshot-3' }); + await settle(); + const watch = socket.request('watch'); + assert.deepEqual( + socket.sent.filter((frame) => 'id' in frame).map(({ method }) => method), + ['get', 'watch'] + ); + assert.deepEqual(watch.params, { runId: 'run-1', fromCursor: 'snapshot-3' }); + socket.respond(watch.id, { subscriptionId: 'watch-1', runId: 'run-1' }); + const result = await pending; + await result.stream.cancel(); + await connection.close(); +}); + +test('close never rejects and mandatory teardown wins cancellation failures', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const client = new ClusterClient(connection); + const logs = await establish(client, socket, 'logs', { subscriptionId: 'logs-1' }); + const pending = client.get(); + await settle(); + socket.sendFailure = new Error('dead socket'); + socket.closeFailure = new Error('close failed'); + await Promise.all([connection.close(), connection.close(), connection.close()]); + await assert.rejects(pending, { code: 'CONNECTION_CLOSED' }); + assertClean(connection); + assert.equal(connection.state, 'CLOSED'); + assert.equal(connection.closeDiagnostics.length, 2); + assert.deepEqual(await logs.stream.next(), { done: true, value: undefined }); +}); + +test('close at a gated send boundary drains the request and closes once', async () => { + const socket = new FakeWebSocket(); + socket.sendGate = deferred(); + const connection = new Connection(socket); + const pending = new ClusterClient(connection).get(); + await settle(); + const closes = [connection.close(), connection.close(), connection.close()]; + socket.sendGate.resolve(); + await assert.rejects(pending, { code: 'CONNECTION_CLOSED' }); + await Promise.all(closes); + assert.equal(socket.closeCalls, 1); + assertClean(connection); +}); + +test('all JSON-RPC and domain errors map to typed errors', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const client = new ClusterClient(connection); + for (const [index, code] of [-32700, -32600, -32601, -32602, -32603, -32000].entries()) { + const pending = client.get(); + await settle(); + const request = socket.request('get', index); + socket.error(request.id, code, 'failure', index === 5 ? 'NOT_FOUND' : undefined); + await assert.rejects( + pending, + (error) => + error instanceof ClusterRpcError && + error.rpcCode === code && + (index !== 5 || error.code === 'NOT_FOUND') + ); + } + await connection.close(); +}); + +test('connect destroys a socket when initialization fails', async () => { + const socket = new FakeWebSocket(); + const pending = connect('ws://cluster', { webSocketFactory: () => socket }); + await settle(); + const request = socket.request('initialize'); + socket.respond(request.id, { + protocolVersion: 'future', + capabilities: {}, + status: { phase: 'empty' }, + }); + await assert.rejects(pending, { code: 'UNSUPPORTED_PROTOCOL_VERSION' }); + assert.equal(socket.readyState, 3); + assert.ok(socket.closeCalls >= 1); +}); diff --git a/tests/cluster/harness.js b/tests/cluster/harness.js new file mode 100644 index 00000000..8829b02b --- /dev/null +++ b/tests/cluster/harness.js @@ -0,0 +1,134 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +class FakeWebSocket { + constructor({ open = true } = {}) { + this.readyState = open ? 1 : 0; + this.sent = []; + this.closeCalls = 0; + this.listeners = new Map(); + this.sendFailure = undefined; + this.sendGate = undefined; + this.closeFailure = undefined; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + this.listeners.set( + type, + listeners.filter((candidate) => candidate !== listener) + ); + } + + emit(type, event) { + for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event); + } + + open() { + this.readyState = 1; + this.emit('open', {}); + } + + send(data) { + if (this.sendFailure) { + const failure = this.sendFailure; + if (failure instanceof Promise) return failure; + throw failure; + } + this.sent.push(JSON.parse(data)); + return this.sendGate?.promise; + } + + close() { + this.closeCalls += 1; + this.readyState = 3; + this.emit('close', {}); + if (this.closeFailure) throw this.closeFailure; + } + + respond(id, result) { + this.emit('message', { data: JSON.stringify({ jsonrpc: '2.0', id, result }) }); + } + + error(id, code, message, domainCode) { + this.emit('message', { + data: JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code, message, ...(domainCode ? { data: { code: domainCode } } : {}) }, + }), + }); + } + + notify(method, params) { + this.emit('message', { data: JSON.stringify({ jsonrpc: '2.0', method, params }) }); + } + + request(method, occurrence = 0) { + return this.sent.filter((frame) => frame.method === method && 'id' in frame)[occurrence]; + } + + notifications(method) { + return this.sent.filter((frame) => frame.method === method && !('id' in frame)); + } +} + +async function settle() { + await Promise.resolve(); + await Promise.resolve(); +} + +function assertClean(connection) { + assert.equal(connection.pendingSize, 0, 'pending requests leaked'); + assert.equal(connection.subscriptionCount, 0, 'subscriptions leaked'); +} + +function filesBelow(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolute = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(absolute) : [absolute]; + }); +} + +function contentsBelow(directory) { + return filesBelow(directory).map((file) => ({ + file, + content: fs.readFileSync(file, 'utf8'), + })); +} + +function connected(Connection, ClusterClient) { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + return { socket, connection, client: new ClusterClient(connection) }; +} + +module.exports = { + FakeWebSocket, + assertClean, + connected, + contentsBelow, + deferred, + filesBelow, + settle, +}; diff --git a/tests/cluster/package.test.js b/tests/cluster/package.test.js new file mode 100644 index 00000000..79fb014f --- /dev/null +++ b/tests/cluster/package.test.js @@ -0,0 +1,199 @@ +'use strict'; + +const assert = require('node:assert').strict; +const fileSystem = require('node:fs'); +const operatingSystem = require('node:os'); +const paths = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { test } = require('node:test'); + +const root = paths.resolve(__dirname, '../..'); + +function execute(command, args, cwd = root) { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +let cachedConsumer; +let cachedRuntimeConsumer; + +function packedConsumer(installOptional = false) { + const cached = installOptional ? cachedRuntimeConsumer : cachedConsumer; + if (cached) return cached; + const directory = fileSystem.mkdtempSync( + paths.join(operatingSystem.tmpdir(), 'zeroshot-cluster-package-') + ); + const output = execute('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + directory, + ]); + const [{ filename, files }] = JSON.parse(output); + const tarball = paths.join(directory, filename); + fileSystem.writeFileSync( + paths.join(directory, 'package.json'), + JSON.stringify({ private: true }) + ); + const installArgs = [ + 'install', + '--ignore-scripts', + ...(installOptional ? [] : ['--omit=optional']), + '--no-package-lock', + '--no-audit', + '--no-fund', + tarball, + ]; + execute('npm', installArgs, directory); + const consumer = { directory, files }; + if (installOptional) cachedRuntimeConsumer = consumer; + else cachedConsumer = consumer; + return consumer; +} + +test('packed tarball resolves CJS, ESM, root, package metadata, and preserved deep imports', () => { + const { directory, files } = packedConsumer(); + const names = new Set(files.map(({ path: file }) => file)); + for (const required of [ + 'lib/cluster/index.cjs', + 'lib/cluster/index.mjs', + 'lib/cluster/index.d.ts', + 'lib/cluster/generated/protocol.d.ts', + ]) + assert.ok(names.has(required), required); + execute( + process.execPath, + [ + '-e', + "const c=require('@the-open-engine/zeroshot/cluster'); if(typeof c.connect!=='function'||typeof c.ClusterClient!=='function')process.exit(1)", + ], + directory + ); + execute( + process.execPath, + [ + '--input-type=module', + '-e', + "import {connect,ClusterClient} from '@the-open-engine/zeroshot/cluster'; if(typeof connect!=='function'||typeof ClusterClient!=='function')process.exit(1)", + ], + directory + ); + execute( + process.execPath, + [ + '-e', + "require('@the-open-engine/zeroshot');require('@the-open-engine/zeroshot/src/orchestrator.js');require('@the-open-engine/zeroshot/lib/settings.js');require('@the-open-engine/zeroshot/package.json')", + ], + directory + ); +}); + +test('packed declarations resolve under node16 and bundler modes', () => { + const { directory } = packedConsumer(); + fileSystem.writeFileSync( + paths.join(directory, 'consumer.ts'), + [ + "import type { ClusterClient, Connection, WatchParams } from '@the-open-engine/zeroshot/cluster';", + 'declare const client: ClusterClient;', + 'declare const connection: Connection;', + 'const params: WatchParams = {};', + 'void client.watch(params);', + "void connection.call('get', {});", + "void connection.openSubscription('watch', {});", + '// @ts-expect-error subscriptions cannot bypass openSubscription', + "void connection.call('watch', {});", + '// @ts-expect-error unary calls cannot use openSubscription', + "void connection.openSubscription('get', {});", + '// @ts-expect-error raw cancellation notifications are ownership-private', + "void connection.sendNotification('subscription/cancel', { subscriptionId: 'guessed' });", + ].join('\n') + ); + const tsc = paths.join(root, 'node_modules/.bin/tsc'); + + for (const [moduleResolution, module] of [ + ['node16', 'Node16'], + ['bundler', 'ES2022'], + ]) { + fileSystem.writeFileSync( + paths.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + moduleResolution, + module, + skipLibCheck: false, + }, + files: ['consumer.ts'], + }) + ); + execute(tsc, ['--project', 'tsconfig.json'], directory); + } +}); + +test('packed CJS and ESM consumers use the installed default ws runtime', () => { + const { directory } = packedConsumer(true); + const script = String.raw` + const { once } = require('node:events'); + const { WebSocketServer } = require('ws'); + (async()=>{ + delete globalThis.WebSocket; + const server = new WebSocketServer({ port: 0 }); + server.on('connection', socket => socket.on('message', data => { + const frame = JSON.parse(data.toString()); + if (frame.method === 'initialize') socket.send(JSON.stringify({ + jsonrpc: '2.0', + id: frame.id, + result: { + protocolVersion: 'openengine.cluster/v1', + capabilities: {}, + status: { phase: 'empty' }, + }, + })); + })); + await once(server, 'listening'); + const address = server.address(); + const url = 'ws://127.0.0.1:' + address.port; + const commonjs = require('@the-open-engine/zeroshot/cluster'); + const first = await commonjs.connect(url); + await first.close(); + const modules = await import('@the-open-engine/zeroshot/cluster'); + const second = await modules.connect(url); + await second.close(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + })().catch(error=>{console.error(error);process.exit(1)}); + `; + execute(process.execPath, ['-e', script], directory); +}); + +test('injected WebSocket factory works with optional runtime omitted', () => { + const { directory } = packedConsumer(); + const script = String.raw` + delete globalThis.WebSocket; + const { connect } = require('@the-open-engine/zeroshot/cluster'); + class Socket { + constructor(){this.readyState=1;this.listeners=new Map()} + addEventListener(t,f){const a=this.listeners.get(t)||[];a.push(f);this.listeners.set(t,a)} + removeEventListener(t,f){this.listeners.set(t,(this.listeners.get(t)||[]).filter(x=>x!==f))} + send(text){const frame=JSON.parse(text);if(frame.method==='initialize')queueMicrotask(()=>this.emit('message',{data:JSON.stringify({jsonrpc:'2.0',id:frame.id,result:{protocolVersion:'openengine.cluster/v1',capabilities:{},status:{phase:'empty'}}})}))} + emit(t,e){for(const f of this.listeners.get(t)||[])f(e)} + close(){this.readyState=3;this.emit('close',{})} + } + (async()=>{ + try { + await connect('ws://example'); + throw new Error('default connection unexpectedly found a WebSocket runtime'); + } catch (error) { + if (error.code !== 'WEBSOCKET_UNAVAILABLE' || !error.message.includes("install 'ws' or pass webSocketFactory")) throw error; + } + const connection = await connect('ws://example',{webSocketFactory:()=>new Socket()}); + await connection.close(); + })().catch(e=>{console.error(e);process.exit(1)}); + `; + execute(process.execPath, ['-e', script], directory); +}); diff --git a/tests/cluster/parity.test.js b/tests/cluster/parity.test.js new file mode 100644 index 00000000..351805eb --- /dev/null +++ b/tests/cluster/parity.test.js @@ -0,0 +1,269 @@ +'use strict'; + +const { strict: assert } = require('node:assert'); +const { readFileSync } = require('node:fs'); +const { join, resolve } = require('node:path'); +const { test } = require('node:test'); +const { + CLUSTER_METHODS, + ClusterClient, + ClusterProtocolError, + Connection, + JSON_RPC_ERROR_CODES, + PROTOCOL_VERSION, + SUBSCRIPTION_QUEUE_CAPACITY, +} = require('../../lib/cluster/index.cjs'); +const { connected, settle } = require('./harness'); + +const root = resolve(__dirname, '../..'); +const protocolRoot = join(root, 'protocol/openengine-cluster/v1'); + +async function open(client, socket, method, result) { + const pending = + method === 'agent/attach' ? client.agentAttach({ execution: 'exec-1' }) : client[method](); + await settle(); + socket.respond(socket.request(method).id, result); + return pending; +} + +test('generated method and error constants match the authoritative artifacts', () => { + const openrpc = JSON.parse(readFileSync(join(protocolRoot, 'openrpc.json'), 'utf8')); + assert.deepEqual( + [...CLUSTER_METHODS], + openrpc.methods.map(({ name }) => name) + ); + assert.equal(CLUSTER_METHODS.length, 12); + const facadeNames = { + initialize: 'initialize', + plan: 'plan', + apply: 'apply', + update: 'update', + stop: 'stop', + retry: 'retry', + resubmit: 'resubmit', + delete: 'delete', + get: 'get', + watch: 'watch', + logs: 'logs', + 'agent/attach': 'agentAttach', + }; + for (const method of CLUSTER_METHODS) + assert.equal(typeof ClusterClient.prototype[facadeNames[method]], 'function', method); + assert.deepEqual( + Object.values(JSON_RPC_ERROR_CODES), + [-32700, -32600, -32601, -32602, -32603, -32000] + ); + const watchRust = readFileSync( + join(root, 'crates/openengine-cluster-protocol/src/watch.rs'), + 'utf8' + ); + assert.equal( + SUBSCRIPTION_QUEUE_CAPACITY, + Number( + watchRust + .match(/DEFAULT_SUBSCRIPTION_QUEUE_CAPACITY: usize = ([0-9_]+)/)[1] + .replaceAll('_', '') + ) + ); +}); + +test('every unary facade sends its named OpenRPC method and returns its response', async () => { + const { socket, connection, client } = connected(Connection, ClusterClient); + const operational = { + dispatchState: 'active', + inFlight: 0, + labels: {}, + logLevel: 'info', + }; + const cases = [ + [ + 'initialize', + { protocolVersion: PROTOCOL_VERSION }, + { protocolVersion: PROTOCOL_VERSION, capabilities: {}, status: { phase: 'empty' } }, + ], + ['plan', { graph: {} }, { ok: true, diagnostics: [] }], + ['apply', { graph: {} }, { phase: 'running', deduped: false }], + [ + 'update', + { ifGeneration: 1, idempotencyKey: 'update-1', suspended: true }, + { + atCursor: 'cursor-2', + deduped: false, + generation: 2, + operational, + phase: 'running', + runId: 'run-1', + }, + ], + [ + 'stop', + { mode: 'drain', ifGeneration: 2, idempotencyKey: 'stop-1' }, + { + acceptedMode: 'drain', + atCursor: 'cursor-3', + deduped: false, + effectiveMode: 'drain', + generation: 3, + operational, + phase: 'running', + runId: 'run-1', + }, + ], + [ + 'retry', + { ifGeneration: 3, idempotencyKey: 'retry-1' }, + { + atCursor: 'cursor-4', + deduped: false, + generation: 4, + operational, + phase: 'running', + retriedTurnId: 'turn-1', + retryTurnId: 'turn-2', + runId: 'run-1', + }, + ], + [ + 'resubmit', + { ifGeneration: 4, ifRunId: 'run-1', idempotencyKey: 'resubmit-1' }, + { + atCursor: 'cursor-5', + deduped: false, + generation: 5, + operational, + phase: 'running', + priorRunId: 'run-1', + runId: 'run-2', + }, + ], + [ + 'delete', + { ifGeneration: 5, idempotencyKey: 'delete-1' }, + { deduped: false, deleted: true, phase: 'empty' }, + ], + ['get', { atCursor: 'cursor-1' }, { status: { phase: 'empty' }, atCursor: 'cursor-1' }], + ]; + for (const [method, params, result] of cases) { + const pending = client[method](params); + await settle(); + const request = socket.request(method); + assert.deepEqual(request.params, params); + socket.respond(request.id, result); + assert.deepEqual(await pending, result); + } + await connection.close(); +}); + +test('watch golden and every remaining watch event variant have caller-visible parity', async () => { + const golden = JSON.parse(readFileSync(join(protocolRoot, 'goldens/watch-session.json'), 'utf8')); + const fault = JSON.parse( + readFileSync(join(protocolRoot, 'fixtures/watch/fault-event.json'), 'utf8') + ); + const extras = [ + { type: 'bookmark' }, + fault, + { type: 'finished', final_status: { phase: 'finished' } }, + ]; + const { socket, connection, client } = connected(Connection, ClusterClient); + const { stream } = await open(client, socket, 'watch', { + subscriptionId: 'sub-1', + runId: 'run-1', + }); + for (const params of golden) socket.notify('event', params); + extras.forEach((event, index) => + socket.notify('event', { + subscriptionId: 'sub-1', + runId: 'run-1', + cursor: `extra-${index}`, + event, + }) + ); + const visible = []; + for (let index = 0; index < golden.length + extras.length; index += 1) + visible.push((await stream.next()).value); + const publicEvents = visible + .slice(0, golden.length) + .map(({ runId, cursor, event }) => ({ runId, cursor, event })); + const goldenEvents = golden.map(({ runId, cursor, event }) => ({ runId, cursor, event })); + assert.deepEqual(publicEvents, goldenEvents); + assert.deepEqual( + visible.slice(golden.length).map(({ event }) => event), + extras + ); + await stream.cancel(); + await connection.close(); +}); + +test('logs and attach goldens replay through the shared subscription machinery', async () => { + for (const descriptor of [ + { + method: 'logs', + file: 'logs-session.json', + result: { subscriptionId: 'sub-1' }, + field: 'record', + }, + { + method: 'agent/attach', + file: 'agent-attach-session.json', + result: { subscriptionId: 'sub-1' }, + field: 'event', + }, + ]) { + const golden = JSON.parse(readFileSync(join(protocolRoot, 'goldens', descriptor.file), 'utf8')); + const { socket, connection, client } = connected(Connection, ClusterClient); + const { stream } = await open(client, socket, descriptor.method, descriptor.result); + for (const params of golden) socket.notify('event', params); + const visible = []; + for (let index = 0; index < golden.length; index += 1) + visible.push((await stream.next()).value.event); + assert.deepEqual( + visible, + golden.map((entry) => entry[descriptor.field]) + ); + await stream.cancel(); + await connection.close(); + } +}); + +test('initialize rejects a server protocol-version mismatch', async () => { + const { socket, connection, client } = connected(Connection, ClusterClient); + const pending = client.initialize(); + await settle(); + socket.respond(socket.request('initialize').id, { + protocolVersion: 'future', + capabilities: {}, + status: { phase: 'empty' }, + }); + await assert.rejects( + pending, + (error) => + error instanceof ClusterProtocolError && error.code === 'UNSUPPORTED_PROTOCOL_VERSION' + ); + await connection.close(); +}); + +test('malformed peer frames never escape the pump and leave the connection open', async () => { + const { socket, connection, client } = connected(Connection, ClusterClient); + for (const frame of [ + '{', + 'null', + '[]', + '{"jsonrpc":"1.0"}', + '{"jsonrpc":"2.0","method":"event","params":{}}', + ]) + socket.emit('message', { data: frame }); + assert.equal(connection.state, 'OPEN'); + assert.equal(connection.protocolDiagnostics.length, 5); + const pending = client.get(); + await settle(); + const request = socket.request('get'); + socket.respond(request.id, { status: { phase: 'empty' } }); + await pending; + await connection.close(); +}); + +test('protocol constants preserve asymmetric serde close reasons and version', () => { + assert.equal(PROTOCOL_VERSION, 'openengine.cluster/v1'); + const schema = JSON.parse(readFileSync(join(protocolRoot, 'schema.json'), 'utf8')); + assert.deepEqual(schema.$defs.SubscriptionCloseReason.enum, ['done', 'SLOW_CONSUMER']); +}); diff --git a/tests/cluster/verifier-regressions.test.js b/tests/cluster/verifier-regressions.test.js new file mode 100644 index 00000000..d05c2efb --- /dev/null +++ b/tests/cluster/verifier-regressions.test.js @@ -0,0 +1,296 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + ClusterClient, + ClusterProtocolError, + Connection, + UNARY_METHODS, + PROTOCOL_DIAGNOSTIC_CAPACITY, + connect, +} = require('../../lib/cluster/index.cjs'); +const { FakeWebSocket, settle } = require('./harness'); + +const SUBSCRIPTIONS = [ + { + method: 'watch', + invoke: (client, options) => client.watch({}, options), + result: { subscriptionId: 'watch-abort', runId: 'run-1' }, + }, + { + method: 'logs', + invoke: (client, options) => client.logs({}, options), + result: { subscriptionId: 'logs-abort' }, + }, + { + method: 'agent/attach', + invoke: (client, options) => client.agentAttach({ execution: 'exec-1' }, options), + result: { subscriptionId: 'attach-abort' }, + }, +]; + +test('subscription signals survive every response handoff and cancel exactly once', async () => { + for (const descriptor of SUBSCRIPTIONS) { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const client = new ClusterClient(connection); + const controller = new AbortController(); + const opening = descriptor.invoke(client, { signal: controller.signal }); + await settle(); + socket.respond(socket.request(descriptor.method).id, descriptor.result); + controller.abort(); + const { stream } = await opening; + assert.equal(connection.subscriptionCount, 0, descriptor.method); + assert.deepEqual(await stream.next(), { done: true, value: undefined }, descriptor.method); + await settle(); + assert.equal(socket.notifications('subscription/cancel').length, 1, descriptor.method); + await stream.cancel(); + assert.equal(socket.notifications('subscription/cancel').length, 1, descriptor.method); + await connection.close(); + } +}); + +test('watch serializes logical reads so dedup cannot reverse concurrent callers', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const opening = new ClusterClient(connection).watch({}); + await settle(); + socket.respond(socket.request('watch').id, { subscriptionId: 'ordered-watch', runId: 'run-1' }); + const { stream } = await opening; + socket.notify('event', { + subscriptionId: 'ordered-watch', + runId: 'run-1', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + assert.equal((await stream.next()).value.cursor, 'cursor-1'); + + const first = stream.next(); + const second = stream.next(); + socket.notify('event', { + subscriptionId: 'ordered-watch', + runId: 'run-1', + cursor: 'cursor-1', + event: { type: 'bookmark' }, + }); + socket.notify('event', { + subscriptionId: 'ordered-watch', + runId: 'run-1', + cursor: 'cursor-2', + event: { type: 'bookmark' }, + }); + socket.notify('subscription/closed', { + subscriptionId: 'ordered-watch', + reason: 'done', + lastDeliveredCursor: 'cursor-2', + }); + assert.equal((await first).value.cursor, 'cursor-2'); + assert.deepEqual((await second).value, { + type: 'closed', + reason: 'done', + lastDeliveredCursor: 'cursor-2', + }); + await connection.close(); +}); + +test('watch dedup state remains bounded across many unique events', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const opening = new ClusterClient(connection).watch({}); + await settle(); + socket.respond(socket.request('watch').id, { + subscriptionId: 'bounded-dedup', + runId: 'run-1', + }); + const { stream } = await opening; + for (let index = 0; index < 4_096; index += 1) { + socket.notify('event', { + subscriptionId: 'bounded-dedup', + runId: 'run-1', + cursor: `cursor-${index}`, + event: { type: 'bookmark' }, + }); + assert.equal((await stream.next()).value.cursor, `cursor-${index}`); + assert.equal(stream.retainedCount, 0); + } + await stream.cancel(); + await connection.close(); +}); + +test('authoritative schemas reject every malformed unary and establishment result', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + for (const method of UNARY_METHODS) { + const invalid = connection.call(method, {}); + await settle(); + socket.respond(socket.request(method).id, { garbage: true }); + await assert.rejects(invalid, (error) => error instanceof ClusterProtocolError, method); + } + + for (const descriptor of SUBSCRIPTIONS) { + const leakedId = `leaked-${descriptor.method}`; + const invalid = descriptor.invoke(new ClusterClient(connection)); + await settle(); + const invalidResult = + descriptor.method === 'watch' + ? { subscriptionId: leakedId, runId: 7 } + : { subscriptionId: leakedId, unexpected: true }; + socket.respond(socket.request(descriptor.method).id, invalidResult); + await assert.rejects( + invalid, + (error) => error instanceof ClusterProtocolError, + descriptor.method + ); + await settle(); + assert.deepEqual(socket.notifications('subscription/cancel').at(-1).params, { + subscriptionId: leakedId, + }); + assert.equal(connection.subscriptionCount, 0); + } + assert.equal(socket.notifications('subscription/cancel').length, SUBSCRIPTIONS.length); + await connection.close(); +}); + +test('authoritative JSON-RPC error envelopes reject noninteger codes and malformed data', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const client = new ClusterClient(connection); + for (const error of [ + { code: -32000.5, message: 'fractional code' }, + { code: -32000, message: 'bad data', data: { code: 7 } }, + ]) { + const pending = client.get(); + await settle(); + const { id } = socket.request( + 'get', + socket.sent.filter((frame) => frame.method === 'get' && 'id' in frame).length - 1 + ); + socket.emit('message', { + data: JSON.stringify({ jsonrpc: '2.0', id, error }), + }); + await assert.rejects( + pending, + (cause) => cause instanceof ClusterProtocolError && cause.code === 'INVALID_RESPONSE' + ); + } + await connection.close(); +}); + +test('authoritative schemas reject wrong subscription event fields', async () => { + const cases = [ + { + method: 'watch', + invoke: (client) => client.watch({}), + result: { subscriptionId: 'invalid-watch', runId: 'run-1' }, + params: { subscriptionId: 'invalid-watch', runId: 'run-1', cursor: 'cursor-1', event: {} }, + }, + { + method: 'logs', + invoke: (client) => client.logs({}), + result: { subscriptionId: 'invalid-logs' }, + params: { subscriptionId: 'invalid-logs', record: {} }, + }, + { + method: 'agent/attach', + invoke: (client) => client.agentAttach({ execution: 'exec-1' }), + result: { subscriptionId: 'invalid-attach' }, + params: { subscriptionId: 'invalid-attach', event: {} }, + }, + ]; + for (const descriptor of cases) { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const opening = descriptor.invoke(new ClusterClient(connection)); + await settle(); + socket.respond(socket.request(descriptor.method).id, descriptor.result); + const { stream } = await opening; + socket.notify('event', descriptor.params); + await assert.rejects( + stream.next(), + (error) => error instanceof ClusterProtocolError, + descriptor.method + ); + assert.equal(connection.subscriptionCount, 0, descriptor.method); + await connection.close(); + } +}); +test('watch close payload is validated before it becomes caller-visible', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const opening = new ClusterClient(connection).watch({}); + await settle(); + socket.respond(socket.request('watch').id, { subscriptionId: 'invalid-watch-close' }); + const { stream } = await opening; + socket.notify('subscription/closed', { + subscriptionId: 'invalid-watch-close', + reason: 'not-a-close-reason', + }); + await assert.rejects(stream.next(), (error) => error instanceof ClusterProtocolError); + await connection.close(); +}); + +test('cursorless subscriptions reject watch-only close cursor fields', async () => { + for (const descriptor of [ + { method: 'logs', invoke: (client) => client.logs({}), id: 'logs-close' }, + { + method: 'agent/attach', + invoke: (client) => client.agentAttach({ execution: 'exec-1' }), + id: 'attach-close', + }, + ]) { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + const opening = descriptor.invoke(new ClusterClient(connection)); + await settle(); + socket.respond(socket.request(descriptor.method).id, { subscriptionId: descriptor.id }); + const { stream } = await opening; + socket.notify('subscription/closed', { + subscriptionId: descriptor.id, + reason: 'done', + lastDeliveredCursor: 'forbidden', + }); + await assert.rejects( + stream.next(), + (error) => error instanceof ClusterProtocolError, + descriptor.method + ); + await connection.close(); + } +}); + +test('public runtime request surface cannot bypass method and cancellation ownership', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + assert.equal(connection.sendNotification, undefined); + assert.throws(() => connection.call('watch', {}), { code: 'INVALID_METHOD' }); + assert.throws(() => connection.openSubscription('get', {}), { code: 'INVALID_METHOD' }); + await assert.rejects(connection.cancelSubscription({ id: 'guessed', cancelSent: false }), { + code: 'UNOWNED_SUBSCRIPTION', + }); + assert.equal(socket.sent.length, 0); + await connection.close(); +}); + +test('connect rejects already-closing and closed sockets without installing waiters', async () => { + for (const readyState of [2, 3]) { + const socket = new FakeWebSocket({ open: false }); + socket.readyState = readyState; + await assert.rejects(connect('ws://example', { webSocketFactory: () => socket }), { + code: 'OPEN_FAILED', + }); + assert.equal([...socket.listeners.values()].flat().length, 0); + assert.equal(socket.closeCalls, 1); + } +}); + +test('malformed-frame diagnostics retain a fixed-capacity recent window', async () => { + const socket = new FakeWebSocket(); + const connection = new Connection(socket); + for (let index = 0; index < 10_000; index += 1) { + socket.emit('message', { data: '{' }); + } + assert.equal(connection.protocolDiagnostics.length, PROTOCOL_DIAGNOSTIC_CAPACITY); + assert.equal(connection.state, 'OPEN'); + await connection.close(); +}); diff --git a/tsconfig.cluster.cjs.json b/tsconfig.cluster.cjs.json new file mode 100644 index 00000000..c36ac51c --- /dev/null +++ b/tsconfig.cluster.cjs.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.cluster.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "rootDir": "src/cluster", + "outDir": ".cluster-build/cjs", + "noEmit": false, + "declaration": true, + "sourceMap": false, + "declarationMap": false + }, + "include": ["src/cluster/**/*.ts"] +} diff --git a/tsconfig.cluster.esm.json b/tsconfig.cluster.esm.json new file mode 100644 index 00000000..cc505c84 --- /dev/null +++ b/tsconfig.cluster.esm.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.cluster.json", + "compilerOptions": { + "module": "ES2022", + "moduleResolution": "Bundler", + "rootDir": "src/cluster", + "outDir": ".cluster-build/esm", + "noEmit": false, + "declaration": false, + "sourceMap": false + }, + "include": ["src/cluster/**/*.ts"] +} diff --git a/tsconfig.cluster.json b/tsconfig.cluster.json new file mode 100644 index 00000000..77fb5fc8 --- /dev/null +++ b/tsconfig.cluster.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022", "ESNext.Disposable"], + "types": ["node"], + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + }, + "include": ["src/cluster/**/*.ts"], + "exclude": ["node_modules", "lib", ".cluster-build"] +}