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/.opcore/config b/.opcore/config index 7ca61300..e1514451 100644 --- a/.opcore/config +++ b/.opcore/config @@ -1,5 +1,10 @@ { "validation": { + "pathPolicy": { + "exclude": [ + "lib/cluster/**" + ] + }, "checks": { "rust": { "functionMetrics": { @@ -7,6 +12,18 @@ "maxComplexity": 10, "maxParams": 6 } + }, + "typescript": { + "fileLength": { + "maxFileLines": 1400 + } + }, + "clone": { + "exclude": [ + "src/cluster/json-guards.ts", + "tests/cluster-client/agent-attach.test.js", + "tests/cluster-client/logs.test.js" + ] } } } diff --git a/eslint.config.mjs b/eslint.config.mjs index e20bf759..ed47d0f4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,7 @@ export default [ clearInterval: 'readonly', setImmediate: 'readonly', clearImmediate: 'readonly', + queueMicrotask: 'readonly', fetch: 'readonly', AbortController: 'readonly', }, @@ -271,6 +272,68 @@ export default [ ], }, }, + { + // Generated file: regenerating via `npm run generate:cluster-protocol-types` is the only fix + // for any drift, so style-only nitpicks about its shape are not actionable here. + files: ['src/cluster/wire-types.generated.ts'], + rules: { + 'sonarjs/redundant-type-aliases': 'off', + 'sonarjs/use-type-alias': 'off', + }, + }, + { + files: ['src/cluster/**/*.ts'], + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: './tsconfig.cluster.json', + tsconfigRootDir, + }, + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-vars': 'off', + // Generated file: not hand-formatted, and re-running the generator is the only fix for drift. + 'max-lines': 'off', + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-check': false, + 'ts-expect-error': true, + 'ts-ignore': true, + 'ts-nocheck': true, + }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-unsafe-type-assertion': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, + }, { // TUI/CLI/streaming files use ANSI escape codes for terminal colors - allow control characters files: [ @@ -329,6 +392,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 76e611e3..355bdba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "ws": "^8.21.1" }, "bin": { "zeroshot": "cli/index.js", @@ -31,6 +32,7 @@ "@semantic-release/github": "^11.0.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.3", + "@types/ws": "^8.18.1", "c8": "^10.1.3", "chai": "^6.2.1", "depcheck": "^1.4.7", @@ -41,6 +43,7 @@ "eslint-plugin-unused-imports": "^4.3.0", "husky": "^9.1.7", "jscpd": "^3.5.10", + "json-schema-to-typescript": "^15.0.4", "lint-staged": "^16.2.7", "mocha": "^11.7.5", "prettier": "^3.7.4", @@ -94,6 +97,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -728,6 +749,13 @@ "spark-md5": "^3.0.1" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -1716,6 +1744,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -1747,6 +1782,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -5925,6 +5970,30 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -12888,6 +12957,27 @@ "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", + "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 4c3774d7..63ab417d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,15 @@ "version": "6.4.0", "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini", "main": "src/orchestrator.js", + "exports": { + ".": "./src/orchestrator.js", + "./cluster": { + "types": "./lib/cluster/types/index.d.ts", + "import": "./lib/cluster/esm/index.js", + "require": "./lib/cluster/cjs/index.js" + }, + "./*": "./*" + }, "bin": { "zeroshot": "./cli/index.js", "zeroshot-agent-provider": "./lib/agent-cli-provider/executable.js", @@ -58,6 +67,13 @@ "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", + "generate:cluster-protocol-types": "node scripts/generate-cluster-protocol-types.js", + "check:cluster-protocol-types": "node scripts/generate-cluster-protocol-types.js --check", + "typecheck:cluster-client": "tsc --project tsconfig.cluster.json", + "lint:cluster-client": "eslint \"src/cluster/**/*.ts\"", + "build:cluster-client": "tsc --project tsconfig.cluster.cjs.build.json && tsc --project tsconfig.cluster.esm.build.json && node scripts/write-cluster-esm-marker.js", + "test:cluster-client": "npm run typecheck:cluster-client && npm run lint:cluster-client && npm run build:cluster-client && node --test tests/cluster-client/*.test.js", + "test:cluster-package": "node scripts/test-cluster-package.js", "dev:link": "npm link", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -76,8 +92,8 @@ "release": "semantic-release", "release:preflight": "node scripts/release-preflight.js", "release:assert-published": "node scripts/assert-release-published.js", - "prepublishOnly": "npm run lint && npm run typecheck && npm run check:agent-cli-provider:ci", - "prepare": "npm run build:agent-cli-provider && husky" + "prepublishOnly": "npm run lint && npm run typecheck && npm run check:agent-cli-provider:ci && npm run test:cluster-client && npm run test:cluster-package", + "prepare": "npm run build:agent-cli-provider && npm run build:cluster-client && husky" }, "c8": { "reporter": [ @@ -148,7 +164,8 @@ "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "ws": "^8.21.1" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", @@ -156,6 +173,7 @@ "@semantic-release/github": "^11.0.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.3", + "@types/ws": "^8.18.1", "c8": "^10.1.3", "chai": "^6.2.1", "depcheck": "^1.4.7", @@ -166,6 +184,7 @@ "eslint-plugin-unused-imports": "^4.3.0", "husky": "^9.1.7", "jscpd": "^3.5.10", + "json-schema-to-typescript": "^15.0.4", "lint-staged": "^16.2.7", "mocha": "^11.7.5", "prettier": "^3.7.4", diff --git a/scripts/generate-cluster-protocol-types.js b/scripts/generate-cluster-protocol-types.js new file mode 100644 index 00000000..755661c4 --- /dev/null +++ b/scripts/generate-cluster-protocol-types.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { compile } = require('json-schema-to-typescript'); + +const PROTOCOL_DIR = path.join(__dirname, '..', 'protocol', 'openengine-cluster', 'v1'); +const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'cluster', 'wire-types.generated.ts'); +const SOURCE_FILES = [ + 'schema.json', + 'graph.schema.json', + 'compiled-ir.schema.json', + 'worker.schema.json', +]; + +// schemars monomorphizes JsonRpcRequest

/JsonRpcResponse/JsonRpcSuccess/JsonRpcNotification

+// once per distinct type parameter, emitting JsonRpcRequest, JsonRpcRequest2, JsonRpcRequest3, ... +// Hand-written generics in envelope.ts (and RpcError/ClusterErrorResponse in errors.ts) supersede +// all of these, so they are excluded here to avoid duplicate/colliding exports. JsonRpcErrorResponse +// is excluded with them (superseded by errors.ts); nothing else references it. RequestId is NOT +// excluded even though envelope.ts also hand-writes it (as `string | number`) — CancelRequestParams +// below still needs a resolvable `#/$defs/RequestId`, and the generated alias is structurally +// identical to the hand-written one, so TypeScript accepts either wherever the other is expected. +const EXCLUDED_DEF_PATTERN = /^JsonRpc(Request|Response|Success|Notification)\d*$/; +const EXCLUDED_DEFS = new Set(['JsonRpcError', 'JsonRpcErrorResponse']); + +const BANNER_COMMENT = [ + '/**', + ' * DO NOT EDIT.', + ' *', + ' * Generated by `npm run generate:cluster-protocol-types` from the authoritative protocol schemas:', + ` * ${SOURCE_FILES.map((file) => `protocol/openengine-cluster/v1/${file}`).join('\n * ')}`, + ' *', + ' * Regenerate after any protocol schema change. `npm run check:cluster-protocol-types` fails CI on drift.', + ' */', +].join('\n'); + +/** + * Merges every `$defs` entry across the four protocol schema files into one map, keyed by + * definition name. `schema.json` is the fully bundled/self-contained artifact (its `$defs` are a + * superset of `graph.schema.json` and `compiled-ir.schema.json`), so it wins on key collision; + * later files only contribute names `schema.json` does not already define (for example + * `worker.schema.json`'s legacy-worker-only types). + */ +function loadMergedDefs() { + const merged = {}; + for (const file of SOURCE_FILES) { + const document = JSON.parse(fs.readFileSync(path.join(PROTOCOL_DIR, file), 'utf8')); + for (const [name, definition] of Object.entries(document.$defs || {})) { + if (!(name in merged)) merged[name] = definition; + } + } + return merged; +} + +function filterDefs(defs) { + const filtered = {}; + for (const [name, definition] of Object.entries(defs)) { + if (EXCLUDED_DEF_PATTERN.test(name) || EXCLUDED_DEFS.has(name)) continue; + filtered[name] = definition; + } + return filtered; +} + +/** + * schemars can emit a definition with both `properties` (the full field set) and a sibling + * `anyOf`/`oneOf` whose branches are refinement-only (`{"required": [...]}`, no `properties`/`type`/ + * `$ref` of their own) — for example `UpdateParams`, whose `anyOf` encodes "at least one of + * labels/logLevel/suspended must be present". json-schema-to-typescript cannot merge that shape with + * the sibling `properties` and degrades the whole definition to `{ [k: string]: unknown }`. That + * "at least one of" refinement is a runtime-only constraint (enforced server-side); it has no + * TypeScript structural-type representation, so it is dropped here, leaving the real `properties`/ + * `required` shape intact for every other definition. + */ +function stripRefinementOnlyUnions(defs) { + const stripped = {}; + for (const [name, definition] of Object.entries(defs)) { + if (!isPlainObject(definition)) { + stripped[name] = definition; + continue; + } + const next = { ...definition }; + for (const key of ['anyOf', 'oneOf']) { + const branches = next[key]; + if (Array.isArray(branches) && branches.every(isRequiredOnlyBranch)) { + delete next[key]; + } + } + stripped[name] = next; + } + return stripped; +} + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isRequiredOnlyBranch(branch) { + return isPlainObject(branch) && Object.keys(branch).every((key) => key === 'required'); +} + +/** + * Compiles the merged, filtered `$defs` map into one TypeScript source string. Every definition is + * emitted (`unreachableDefinitions: true`) even though the synthetic root schema below has no + * properties of its own — the root exists only to give json-schema-to-typescript a document to + * resolve internal `#/$defs/...` refs against. + */ +async function compileWireTypes(defs) { + const rootSchema = { + title: 'ClusterWireTypesRoot', + type: 'object', + $defs: defs, + properties: {}, + additionalProperties: false, + }; + const output = await compile(rootSchema, 'ClusterWireTypesRoot', { + bannerComment: BANNER_COMMENT, + unreachableDefinitions: true, + style: { singleQuote: true, printWidth: 100 }, + }); + return output.replace(/export interface ClusterWireTypesRoot \{[^}]*\}\n\n?/, ''); +} + +function generate() { + return compileWireTypes(stripRefinementOnlyUnions(filterDefs(loadMergedDefs()))); +} + +async function main() { + const checkMode = process.argv.includes('--check'); + const generated = await generate(); + + if (!checkMode) { + fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + fs.writeFileSync(OUTPUT_FILE, generated); + console.log(`Generated ${path.relative(process.cwd(), OUTPUT_FILE)}`); + return; + } + + const existing = fs.existsSync(OUTPUT_FILE) ? fs.readFileSync(OUTPUT_FILE, 'utf8') : null; + if (existing !== generated) { + console.error( + 'src/cluster/wire-types.generated.ts is out of date with the protocol schemas.\n' + + 'Run `npm run generate:cluster-protocol-types` and commit the result.' + ); + process.exit(1); + } + console.log('src/cluster/wire-types.generated.ts is up to date.'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-cluster-package.js b/scripts/test-cluster-package.js new file mode 100644 index 00000000..2a2b582c --- /dev/null +++ b/scripts/test-cluster-package.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.join(__dirname, '..'); + +function run(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', ...options }); +} + +/** + * Packs the repo, extracts the tarball into a scratch `node_modules/@the-open-engine/zeroshot` + * (rather than running a real `npm install`, which would redundantly re-resolve every dependency + * this repo already has installed), and symlinks every OTHER package from the repo's own + * `node_modules` alongside it so the extracted package's real dependencies (`ws` for `./cluster`, + * plus everything the default entrypoint needs) resolve. This exercises Node's real `exports`-map + * resolution through a bare specifier — a relative `require()` of the extracted files would bypass + * the exports map entirely and prove nothing about AC7. + */ +function packAndInstallInto(consumerDir) { + const packOutputRaw = run('npm', ['pack', '--json', '--pack-destination', os.tmpdir()], { + cwd: repoRoot, + }); + const packResults = JSON.parse(packOutputRaw); + const tarballPath = path.join(os.tmpdir(), packResults[0].filename); + + const consumerNodeModules = path.join(consumerDir, 'node_modules'); + const scopeDir = path.join(consumerNodeModules, '@the-open-engine'); + const packageDir = path.join(scopeDir, 'zeroshot'); + fs.mkdirSync(scopeDir, { recursive: true }); + run('tar', ['xzf', tarballPath, '-C', scopeDir]); + fs.renameSync(path.join(scopeDir, 'package'), packageDir); + fs.rmSync(tarballPath, { force: true }); + + const repoNodeModules = path.join(repoRoot, 'node_modules'); + for (const entry of fs.readdirSync(repoNodeModules)) { + if (entry === '@the-open-engine' || entry === '.bin') continue; + fs.symlinkSync(path.join(repoNodeModules, entry), path.join(consumerNodeModules, entry), 'dir'); + } + + return packageDir; +} + +function writeAndRun(consumerDir, filename, source) { + const filePath = path.join(consumerDir, filename); + fs.writeFileSync(filePath, source); + return run('node', [filePath], { cwd: consumerDir }); +} + +function main() { + const consumerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-cluster-package-')); + try { + const packageDir = packAndInstallInto(consumerDir); + + const cjsOutput = writeAndRun( + consumerDir, + 'require-probe.cjs', + ` + const assert = require('node:assert/strict'); + const cluster = require('@the-open-engine/zeroshot/cluster'); + assert.equal(typeof cluster.ClusterClient, 'function'); + assert.equal(typeof cluster.WebSocketTransport, 'function'); + assert.equal(typeof cluster.DurableWatchClient, 'function'); + assert.equal(typeof cluster.connectCluster, 'function'); + + const defaultEntry = require('@the-open-engine/zeroshot'); + assert.ok(defaultEntry, 'default entrypoint must still resolve'); + + const deepImport = require('@the-open-engine/zeroshot/lib/cluster-worker/index.js'); + assert.ok(deepImport, 'pre-existing deep import must still resolve unchanged'); + + console.log('CJS_PROBE_OK'); + ` + ); + assert.match(cjsOutput, /CJS_PROBE_OK/, `CJS probe failed:\n${cjsOutput}`); + + const esmOutput = writeAndRun( + consumerDir, + 'import-probe.mjs', + ` + import assert from 'node:assert/strict'; + import * as cluster from '@the-open-engine/zeroshot/cluster'; + assert.equal(typeof cluster.ClusterClient, 'function'); + assert.equal(typeof cluster.DurableWatchClient, 'function'); + console.log('ESM_PROBE_OK'); + ` + ); + assert.match(esmOutput, /ESM_PROBE_OK/, `ESM probe failed:\n${esmOutput}`); + + const dtsPath = path.join(packageDir, 'lib', 'cluster', 'types', 'index.d.ts'); + assert.ok(fs.existsSync(dtsPath), `missing published type declarations: ${dtsPath}`); + const dtsContent = fs.readFileSync(dtsPath, 'utf8'); + assert.match(dtsContent, /ClusterClient/, 'published .d.ts does not reference ClusterClient'); + + console.log('test:cluster-package passed'); + } finally { + fs.rmSync(consumerDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/write-cluster-esm-marker.js b/scripts/write-cluster-esm-marker.js new file mode 100644 index 00000000..84fa4804 --- /dev/null +++ b/scripts/write-cluster-esm-marker.js @@ -0,0 +1,15 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const esmDir = path.join(__dirname, '..', 'lib', 'cluster', 'esm'); + +if (!fs.existsSync(esmDir)) { + throw new Error(`cluster ESM build output missing: ${esmDir} (run build:cluster-client first)`); +} + +fs.writeFileSync( + path.join(esmDir, 'package.json'), + `${JSON.stringify({ type: 'module' }, null, 2)}\n` +); diff --git a/src/cluster/agent-attach-subscription.ts b/src/cluster/agent-attach-subscription.ts new file mode 100644 index 00000000..1b4e3ac2 --- /dev/null +++ b/src/cluster/agent-attach-subscription.ts @@ -0,0 +1,56 @@ +import { CapabilityNotSupportedError, InvalidResponseError } from './errors.js'; +import { isRecord } from './json-guards.js'; +import { + establishEventSubscription, + type ClusterSubscriptionTransport, + type CursorlessEventStream, + type EventOrClosed, +} from './subscription-stream.js'; +import type { + AgentAttachEvent, + AgentAttachParams, + AgentAttachResult, + ServerCapabilities, +} from './wire-types.generated.js'; + +export type AgentAttachEventOrClosed = EventOrClosed; + +function extractAgentAttachEvent(params: Record): AgentAttachEvent { + const event = params.event; + if (!isRecord(event)) throw new InvalidResponseError('agent-attach event notification missing event'); + // Trust the wire boundary for the event's variant shape, same as every other generated wire type + // at this transport layer — see envelope.ts's parseUnaryResponseLine. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return event as unknown as AgentAttachEvent; +} + +/** + * Typed `agent/attach` subscription client. Cursorless, capability-gated, and read-only: scoped to + * a single `ExecutionRef` rather than being cluster-wide, with no run scoping and no replay. Mirrors + * crates/openengine-cluster-client/src/ndjson_agent_attach.rs. + */ +export class AgentAttachSubscriptionClient { + private readonly transport: ClusterSubscriptionTransport; + + constructor(transport: ClusterSubscriptionTransport) { + this.transport = transport; + } + + /** + * @param capabilities The server's advertised capabilities, from a prior `initialize()` call. + * Throws {@link CapabilityNotSupportedError} before opening any connection if + * `capabilities.agentAttach` is falsy. + */ + agentAttach( + params: AgentAttachParams, + capabilities: ServerCapabilities + ): Promise<{ result: AgentAttachResult; stream: CursorlessEventStream }> { + if (!capabilities.agentAttach) throw new CapabilityNotSupportedError('agentAttach'); + return establishEventSubscription( + this.transport, + 'agent/attach', + params, + extractAgentAttachEvent + ); + } +} diff --git a/src/cluster/cluster-client.ts b/src/cluster/cluster-client.ts new file mode 100644 index 00000000..14f38361 --- /dev/null +++ b/src/cluster/cluster-client.ts @@ -0,0 +1,158 @@ +import { + JSON_RPC_VERSION, + PROTOCOL_VERSION, + parseUnaryResponseLine, + type JsonRpcRequest, + type RequestId, +} from './envelope.js'; +import { AbortError, InvalidResponseError } from './errors.js'; +import type { PumpedResponse } from './multiplexed-transport.js'; +import type { + ApplyParams, + ApplyResult, + DeleteParams, + DeleteResult, + GetParams, + GetResult, + InitializeResult, + PlanParams, + PlanResult, + ResubmitParams, + ResubmitResult, + RetryParams, + RetryResult, + StopParams, + StopResult, + UpdateParams, + UpdateResult, +} from './wire-types.generated.js'; + +/** What {@link ClusterClient} needs from a transport: shared id allocation plus unary request/cancel. */ +export interface ClusterRequestTransport { + sendRequest(serialized: string, id: RequestId): Promise; + nextRequestId(): RequestId; + cancelRequest(id: RequestId): Promise; +} + +export interface ClusterCallOptions { + readonly signal?: AbortSignal; +} + +function abortSignalRejection(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(new AbortError()); + return; + } + signal.addEventListener( + 'abort', + () => { + reject(new AbortError()); + }, + { once: true } + ); + }); +} + +/** + * Typed transport-neutral Cluster Protocol client. Mirrors + * crates/openengine-cluster-client/src/lib.rs's `ClusterClient`, except unary request ids are + * minted by the shared transport (`transport.nextRequestId()`) rather than a per-client counter — + * intentionally, so two `ClusterClient`s sharing one `MultiplexedTransport` never collide (see + * `MultiplexedTransport`'s doc comment). + */ +export class ClusterClient { + private readonly transport: ClusterRequestTransport; + + constructor(transport: ClusterRequestTransport) { + this.transport = transport; + } + + async initialize(options?: ClusterCallOptions): Promise { + const result = await this.call<{ protocolVersion: string }, InitializeResult>( + 'initialize', + { protocolVersion: PROTOCOL_VERSION }, + options + ); + if (result.protocolVersion !== PROTOCOL_VERSION) { + throw new InvalidResponseError( + `protocol version mismatch: requested ${PROTOCOL_VERSION}, received ${result.protocolVersion}` + ); + } + return result; + } + + plan(params: PlanParams, options?: ClusterCallOptions): Promise { + return this.call('plan', params, options); + } + + apply(params: ApplyParams, options?: ClusterCallOptions): Promise { + return this.call('apply', params, options); + } + + get(params: GetParams, options?: ClusterCallOptions): Promise { + return this.call('get', params, options); + } + + update(params: UpdateParams, options?: ClusterCallOptions): Promise { + return this.call('update', params, options); + } + + stop(params: StopParams, options?: ClusterCallOptions): Promise { + return this.call('stop', params, options); + } + + retry(params: RetryParams, options?: ClusterCallOptions): Promise { + return this.call('retry', params, options); + } + + resubmit(params: ResubmitParams, options?: ClusterCallOptions): Promise { + return this.call('resubmit', params, options); + } + + delete(params: DeleteParams, options?: ClusterCallOptions): Promise { + return this.call('delete', params, options); + } + + /** + * Sends one unary request and parses its typed result. When `options.signal` fires, `$/cancelRequest` + * is sent exactly once (an idempotent flag guards a second `signal`/manual trigger) and this call + * rejects locally without waiting for the transport to actually settle — the eventual settlement is + * swallowed so it never surfaces as an unhandled rejection. + */ + private async call(method: string, params: P, options?: ClusterCallOptions): Promise { + const signal = options?.signal; + if (signal?.aborted) throw new AbortError(); + + const id = this.transport.nextRequestId(); + const request: JsonRpcRequest

= { jsonrpc: JSON_RPC_VERSION, id, method, params }; + const responsePromise = this.transport.sendRequest(JSON.stringify(request), id); + + if (!signal) { + const response = await responsePromise; + return parseUnaryResponseLine(response.line, id); + } + + let cancelled = false; + const cancelOnce = (): void => { + if (cancelled) return; + cancelled = true; + this.transport.cancelRequest(id).catch(() => { + // Best-effort: the connection may already be gone. + }); + }; + + try { + const response = await Promise.race([responsePromise, abortSignalRejection(signal)]); + return parseUnaryResponseLine(response.line, id); + } catch (error) { + if (error instanceof AbortError) { + cancelOnce(); + responsePromise.catch(() => { + // Swallow the eventual settlement; the caller already saw the abort rejection. + }); + } + throw error; + } + } +} diff --git a/src/cluster/durable-watch.ts b/src/cluster/durable-watch.ts new file mode 100644 index 00000000..42ebd593 --- /dev/null +++ b/src/cluster/durable-watch.ts @@ -0,0 +1,96 @@ +import { ClusterClient } from './cluster-client.js'; +import { iterateUntilClosed } from './subscription-stream.js'; +import { + WatchSubscriptionClient, + type WatchEventOrClosed, + type WatchSubscriptionEventStream, +} from './watch-subscription.js'; +import { WebSocketTransport, type WebSocketFactory } from './websocket-transport.js'; +import type { WatchParams } from './wire-types.generated.js'; + +export interface DurableWatchOptions { + readonly webSocketFactory?: WebSocketFactory; + readonly subscriptionQueueCapacity?: number; +} + +/** + * Full-socket durable watch: on connection loss, dials a brand-new WebSocket, builds a fresh + * transport and `ClusterClient` on it, calls `get()` for a coherent snapshot, then re-establishes + * `watch({ runId, fromCursor: lastDelivered })` — exclusively on that fresh transport, never the + * dead one. The `(runId, cursor)` dedup set is carried across every reconnect, in-process (this has + * no direct Rust reference implementation: Rust's own `reconnect` is subscription-level over one + * still-live transport; the socket-level reconnect this class performs is TypeScript-specific). + */ +export class DurableWatchClient { + private readonly url: string; + private readonly options: DurableWatchOptions; + private transport: WebSocketTransport; + private stream: WatchSubscriptionEventStream; + private closing = false; + + private constructor( + url: string, + options: DurableWatchOptions, + transport: WebSocketTransport, + stream: WatchSubscriptionEventStream + ) { + this.url = url; + this.options = options; + this.transport = transport; + this.stream = stream; + } + + static async connect( + url: string, + params: WatchParams, + options: DurableWatchOptions = {} + ): Promise { + const transport = await WebSocketTransport.connect(url, options); + const { stream } = await new WatchSubscriptionClient(transport).watch(params); + return new DurableWatchClient(url, options, transport, stream); + } + + /** + * Returns the next logically new event or terminal close. Returns `null` only after an explicit + * local {@link close}; a mid-stream connection loss triggers exactly one reconnect attempt and + * transparently resumes instead of surfacing `null`. Reconnect failure propagates as a rejection. + */ + async next(): Promise { + for (;;) { + const outcome = await this.stream.next(); + if (outcome !== null) return outcome; + if (this.closing) return null; + await this.reconnectFullSocket(); + } + } + + private async reconnectFullSocket(): Promise { + const runId = this.stream.currentRunId(); + const fromCursor = this.stream.lastDeliveredCursor(); + const dedup = this.stream.dedupSet(); + + const freshTransport = await WebSocketTransport.connect(this.url, this.options); + await new ClusterClient(freshTransport).get({}); + const { stream } = await new WatchSubscriptionClient(freshTransport).watchWithDedup( + { runId, fromCursor }, + dedup + ); + + this.transport.close(); + this.transport = freshTransport; + this.stream = stream; + } + + /** Cancels the live subscription and closes the connection. Idempotent. */ + async close(): Promise { + if (this.closing) return; + this.closing = true; + await this.stream.cancel(); + this.transport.close(); + } + + /** Makes this client directly usable with `for await` — see {@link iterateUntilClosed}. */ + [Symbol.asyncIterator](): AsyncGenerator { + return iterateUntilClosed(() => this.next(), () => this.close()); + } +} diff --git a/src/cluster/envelope.ts b/src/cluster/envelope.ts new file mode 100644 index 00000000..fc97c942 --- /dev/null +++ b/src/cluster/envelope.ts @@ -0,0 +1,106 @@ +import { InvalidResponseError, RpcError } from './errors.js'; +import { isRecord, parseJson } from './json-guards.js'; +import type { DomainErrorData, RequestId } from './wire-types.generated.js'; + +export type { RequestId }; + +export const JSON_RPC_VERSION = '2.0'; +export const PROTOCOL_VERSION = 'openengine.cluster/v1'; + +/** + * Hand-written generic JSON-RPC envelope. The protocol schemas monomorphize this once per distinct + * `params`/`result` type (`JsonRpcRequest`, `JsonRpcRequest2`, ...); this single generic supersedes + * all of them so callers get one parameterized type instead of N structurally-identical ones. + */ +export interface JsonRpcRequest

{ + readonly jsonrpc: typeof JSON_RPC_VERSION; + readonly id: RequestId; + readonly method: string; + readonly params: P; +} + +export interface JsonRpcNotification

{ + readonly jsonrpc: typeof JSON_RPC_VERSION; + readonly method: string; + readonly params: P; +} + +export interface JsonRpcSuccess { + readonly jsonrpc: string; + readonly id: RequestId; + readonly result: R; +} + +export function requestIdKey(id: RequestId): string { + return typeof id === 'number' ? `n:${id}` : `s:${id}`; +} + +export function requestIdsEqual(a: RequestId | null | undefined, b: RequestId): boolean { + if (a === null || a === undefined) return false; + return requestIdKey(a) === requestIdKey(b); +} + +/** @returns the decoded id, or `null` if `value` is not a valid JSON-RPC id shape. */ +function extractRequestId(value: unknown): RequestId | null { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isInteger(value)) return value; + return null; +} + +function toDomainErrorData(value: unknown): DomainErrorData | undefined { + if (!isRecord(value)) return undefined; + const code = value.code; + if (typeof code !== 'string') return undefined; + return { code, details: 'details' in value ? value.details : undefined }; +} + +export function validateResponseIdentity( + jsonrpc: string, + actualId: RequestId | null, + expectedId: RequestId +): void { + if (jsonrpc !== JSON_RPC_VERSION) { + throw new InvalidResponseError(`expected jsonrpc ${JSON_RPC_VERSION}, received ${jsonrpc}`); + } + if (actualId === null || requestIdKey(actualId) !== requestIdKey(expectedId)) { + throw new InvalidResponseError( + `response id mismatch: expected ${requestIdKey(expectedId)}, received ${ + actualId === null ? 'none' : requestIdKey(actualId) + }` + ); + } +} + +/** + * Parses one demultiplexed unary response line into its typed result, throwing {@link RpcError} for + * a well-formed JSON-RPC error and {@link InvalidResponseError} for anything malformed or whose + * `jsonrpc`/`id` does not match `expectedId`. `R` is trusted at the JSON boundary the same way + * `serde_json::from_value::` is trusted in the Rust client — this crate does not re-validate + * every generated wire type's shape at runtime. + */ +export function parseUnaryResponseLine(line: string, expectedId: RequestId): R { + const value: unknown = parseJson(line); + if (!isRecord(value)) throw new InvalidResponseError('response is not a JSON object'); + const jsonrpc = value.jsonrpc; + if (typeof jsonrpc !== 'string') throw new InvalidResponseError('response missing jsonrpc field'); + const actualId = extractRequestId(value.id); + + if ('error' in value) { + const errorValue = value.error; + if (!isRecord(errorValue) || typeof errorValue.code !== 'number' || typeof errorValue.message !== 'string') { + throw new InvalidResponseError('malformed JSON-RPC error response'); + } + validateResponseIdentity(jsonrpc, actualId, expectedId); + throw new RpcError({ + code: errorValue.code, + message: errorValue.message, + data: toDomainErrorData(errorValue.data), + }); + } + + validateResponseIdentity(jsonrpc, actualId, expectedId); + // Trust the wire boundary for the result payload shape, exactly like `serde_json::from_value::` + // does in the Rust client — this module does not re-validate every generated wire type at runtime. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return value.result as R; +} diff --git a/src/cluster/errors.ts b/src/cluster/errors.ts new file mode 100644 index 00000000..829a9f72 --- /dev/null +++ b/src/cluster/errors.ts @@ -0,0 +1,65 @@ +import type { DomainErrorData } from './wire-types.generated.js'; + +// JSON-RPC 2.0 reserved error codes plus the protocol's application-error band. +// crates/openengine-cluster-protocol/src/lib.rs:47-52 is the authoritative source. +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; +export const APPLICATION_ERROR = -32000; + +export interface RpcErrorPayload { + readonly code: number; + readonly message: string; + readonly data?: DomainErrorData | undefined; +} + +/** Base type for every error this client throws. */ +export class ClusterClientError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** The connection/transport failed to send or receive a frame. */ +export class ClusterTransportError extends ClusterClientError { + constructor(message: string) { + super(message); + } +} + +/** The server returned a well-formed JSON-RPC error response. */ +export class RpcError extends ClusterClientError { + readonly code: number; + readonly data?: DomainErrorData | undefined; + + constructor(payload: RpcErrorPayload) { + super(payload.message); + this.code = payload.code; + this.data = payload.data; + } +} + +/** A response could not be parsed, or its `jsonrpc`/`id` did not match the request. */ +export class InvalidResponseError extends ClusterClientError { + constructor(message: string) { + super(message); + } +} + +/** A call was aborted locally via `AbortSignal` or async-iterator `return()`. */ +export class AbortError extends ClusterClientError { + constructor(message = 'operation aborted') { + super(message); + } +} + +/** A subscription capability the server did not advertise was requested. */ +export class CapabilityNotSupportedError extends ClusterClientError { + constructor(capability: string) { + super(`server does not advertise the '${capability}' capability`); + } +} diff --git a/src/cluster/index.ts b/src/cluster/index.ts new file mode 100644 index 00000000..c24ebb71 --- /dev/null +++ b/src/cluster/index.ts @@ -0,0 +1,28 @@ +export * from './wire-types.generated.js'; +export * from './envelope.js'; +export * from './errors.js'; +export * from './multiplexed-transport.js'; +export * from './subscription-stream.js'; +export * from './websocket-transport.js'; +export * from './cluster-client.js'; +export * from './watch-subscription.js'; +export * from './durable-watch.js'; +export * from './logs-subscription.js'; +export * from './agent-attach-subscription.js'; + +import { ClusterClient } from './cluster-client.js'; +import { WebSocketTransport, type WebSocketTransportOptions } from './websocket-transport.js'; + +/** + * Convenience entrypoint: dials `url`, waits for the connection to open, and returns a + * `ClusterClient` ready for unary calls plus the underlying `WebSocketTransport` (needed to build a + * `WatchSubscriptionClient`/`LogsSubscriptionClient`/`AgentAttachSubscriptionClient`/`DurableWatchClient` + * sharing the same connection). + */ +export async function connectCluster( + url: string, + options?: WebSocketTransportOptions +): Promise<{ client: ClusterClient; transport: WebSocketTransport }> { + const transport = await WebSocketTransport.connect(url, options); + return { client: new ClusterClient(transport), transport }; +} diff --git a/src/cluster/json-guards.ts b/src/cluster/json-guards.ts new file mode 100644 index 00000000..a5555691 --- /dev/null +++ b/src/cluster/json-guards.ts @@ -0,0 +1,31 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseJson(line: string): unknown { + const parsed: unknown = JSON.parse(line); + return parsed; +} + +export function getString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === 'string' ? value : null; +} + +export function getRecord( + record: Record, + key: string +): Record | null { + const value = record[key]; + return isRecord(value) ? value : null; +} + +export function unknownToMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (isRecord(error)) { + const message = error.message; + if (typeof message === 'string' && message) return message; + } + return String(error); +} diff --git a/src/cluster/logs-subscription.ts b/src/cluster/logs-subscription.ts new file mode 100644 index 00000000..12515bcf --- /dev/null +++ b/src/cluster/logs-subscription.ts @@ -0,0 +1,52 @@ +import { CapabilityNotSupportedError, InvalidResponseError } from './errors.js'; +import { isRecord } from './json-guards.js'; +import { + establishEventSubscription, + type ClusterSubscriptionTransport, + type CursorlessEventStream, + type EventOrClosed, +} from './subscription-stream.js'; +import type { LogRecord, LogsParams, LogsResult, ServerCapabilities } from './wire-types.generated.js'; + +export type LogEventOrClosed = EventOrClosed; + +function extractLogRecord(params: Record): LogRecord { + const record = params.record; + if (!isRecord(record)) throw new InvalidResponseError('log event notification missing record'); + // Trust the wire boundary for the record's field shape (level/target/message), same as every + // other generated wire type at this transport layer — see envelope.ts's parseUnaryResponseLine. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return record as unknown as LogRecord; +} + +/** + * Typed `logs` subscription client. Cursorless and capability-gated: `logs` has no run scoping and + * no replay, unlike `watch`. Mirrors + * crates/openengine-cluster-client/src/ndjson_logs.rs (generated there from the shared + * `impl_ndjson_event_subscription!` macro; here from {@link establishEventSubscription}). + */ +export class LogsSubscriptionClient { + private readonly transport: ClusterSubscriptionTransport; + + constructor(transport: ClusterSubscriptionTransport) { + this.transport = transport; + } + + /** + * @param capabilities The server's advertised capabilities, from a prior `initialize()` call. + * Throws {@link CapabilityNotSupportedError} before opening any connection if `capabilities.logs` + * is falsy. + */ + logs( + params: LogsParams, + capabilities: ServerCapabilities + ): Promise<{ result: LogsResult; stream: CursorlessEventStream }> { + if (!capabilities.logs) throw new CapabilityNotSupportedError('logs'); + return establishEventSubscription( + this.transport, + 'logs', + params, + extractLogRecord + ); + } +} diff --git a/src/cluster/multiplexed-transport.ts b/src/cluster/multiplexed-transport.ts new file mode 100644 index 00000000..40bc78a3 --- /dev/null +++ b/src/cluster/multiplexed-transport.ts @@ -0,0 +1,249 @@ +import { JSON_RPC_VERSION, requestIdKey, type JsonRpcNotification, type RequestId } from './envelope.js'; +import { ClusterTransportError } from './errors.js'; +import { getRecord, getString, isRecord, parseJson } from './json-guards.js'; + +/** Matches the Rust client/server's `SUBSCRIPTION_QUEUE_CAPACITY` (crates/openengine-cluster-client/src/lib.rs). */ +export const SUBSCRIPTION_QUEUE_CAPACITY = 1024; + +/** Abstraction over "write one already-serialized JSON-RPC frame to the peer". */ +export interface FrameSink { + sendFrame(frame: string): Promise; +} + +/** + * Bounded per-subscription local buffer of raw notification lines, shared by watch/logs/agent-attach + * subscription clients. Delivery is non-blocking (`tryPush`) so one abandoned subscription cannot + * stall the connection's sole response pump — mirrors `tokio::sync::mpsc::channel(1024)` plus the + * `overflowed: Arc` flag read once the channel drains, from + * crates/openengine-cluster-client/src/ndjson_pump.rs. + */ +export class BoundedSubscriptionQueue { + private readonly capacity: number; + private readonly buffer: string[] = []; + private resolveWaiting: ((line: string | null) => void) | null = null; + private closed = false; + overflowed = false; + + constructor(capacity: number = SUBSCRIPTION_QUEUE_CAPACITY) { + this.capacity = capacity; + } + + /** Non-blocking; mirrors `mpsc::Sender::try_send`. */ + tryPush(line: string): 'ok' | 'full' | 'closed' { + if (this.closed) return 'closed'; + if (this.resolveWaiting) { + const resolve = this.resolveWaiting; + this.resolveWaiting = null; + resolve(line); + return 'ok'; + } + if (this.buffer.length >= this.capacity) return 'full'; + this.buffer.push(line); + return 'ok'; + } + + /** Marks local overflow and closes the queue, mirroring the Rust pump's full-channel handling. */ + markOverflowed(): void { + this.overflowed = true; + this.end(); + } + + /** Mirrors `mpsc::Receiver::recv() -> Option`: resolves `null` once the channel ends. */ + recv(): Promise { + if (this.buffer.length > 0) return Promise.resolve(this.buffer.shift() ?? null); + if (this.closed) return Promise.resolve(null); + return new Promise((resolve) => { + this.resolveWaiting = resolve; + }); + } + + /** Ends the channel without marking overflow — mirrors dropping the sender (pump end, cancel). */ + end(): void { + if (this.closed) return; + this.closed = true; + if (this.resolveWaiting) { + const resolve = this.resolveWaiting; + this.resolveWaiting = null; + resolve(null); + } + } +} + +export interface PumpedResponse { + readonly line: string; + readonly queue: BoundedSubscriptionQueue | null; +} + +interface PendingEntry { + resolve(response: PumpedResponse): void; + reject(error: Error): void; +} + +/** @returns the decoded id, or `null` if `value` is not a valid JSON-RPC id shape. */ +function extractResponseId(value: unknown): RequestId | null { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isInteger(value)) return value; + return null; +} + +/** + * Owns one connection's demultiplexing state: the write sink, the shared pending-unary-response map, + * the shared subscription-notification map, and the SOLE request-id/watch-id allocators for every + * caller sharing this transport. Two `ClusterClient`s (or a `ClusterClient` and a subscription + * client) backed by the same `MultiplexedTransport` therefore can never allocate colliding request + * ids, unlike a per-client counter — see crates/openengine-cluster-client/src/multiplex.rs, whose + * demux design this mirrors. + */ +export class MultiplexedTransport { + private readonly sink: FrameSink; + private readonly subscriptionQueueCapacity: number; + private readonly pending = new Map(); + private readonly subscriptions = new Map(); + private requestIdCounter = 1; + private watchIdCounter = 1; + + constructor(sink: FrameSink, options?: { subscriptionQueueCapacity?: number | undefined }) { + this.sink = sink; + this.subscriptionQueueCapacity = options?.subscriptionQueueCapacity ?? SUBSCRIPTION_QUEUE_CAPACITY; + } + + /** + * Mints the next unary JSON-RPC request id, shared across every caller of this transport. + * @returns an integer request id. + */ + nextRequestId(): RequestId { + const id = this.requestIdCounter; + this.requestIdCounter += 1; + return id; + } + + /** + * Mints the next subscription-establishing request id, in the `watch-` shape Rust uses. + * @returns a `watch-` string request id. + */ + nextWatchRequestId(): RequestId { + const id = `watch-${this.watchIdCounter}`; + this.watchIdCounter += 1; + return id; + } + + /** + * Registers `id` as pending, writes `serialized`, and awaits its demultiplexed response. Shared + * by unary calls and subscription-establishing calls alike (the caller decides whether to expect + * `PumpedResponse.queue`). On a `sendFrame` failure the pending entry is synchronously removed + * before the error propagates, so a closed-transport send never leaks a pending entry. + */ + async sendRequest(serialized: string, id: RequestId): Promise { + const key = requestIdKey(id); + if (this.pending.has(key)) { + throw new ClusterTransportError(`request id is already pending: ${key}`); + } + const responsePromise = new Promise((resolve, reject) => { + this.pending.set(key, { resolve, reject }); + }); + try { + await this.sink.sendFrame(serialized); + } catch (error) { + this.pending.delete(key); + throw error instanceof Error ? error : new ClusterTransportError(String(error)); + } + return responsePromise; + } + + async cancelSubscription(subscriptionId: string): Promise { + const notification: JsonRpcNotification<{ subscriptionId: string }> = { + jsonrpc: JSON_RPC_VERSION, + method: 'subscription/cancel', + params: { subscriptionId }, + }; + await this.sink.sendFrame(JSON.stringify(notification)); + } + + async cancelRequest(id: RequestId): Promise { + const notification: JsonRpcNotification<{ id: RequestId }> = { + jsonrpc: JSON_RPC_VERSION, + method: '$/cancelRequest', + params: { id }, + }; + await this.sink.sendFrame(JSON.stringify(notification)); + } + + /** Decodes and routes one pumped line. Malformed/unroutable frames are silently dropped. */ + routeIncoming(line: string): void { + let value: unknown; + try { + value = parseJson(line); + } catch { + return; + } + if (!isRecord(value)) return; + if (typeof value.method === 'string') { + this.routeNotification(value, line); + return; + } + this.routeResponse(value, line); + } + + private routeResponse(value: Record, line: string): void { + const id = extractResponseId(value.id); + if (id === null) return; + const key = requestIdKey(id); + const pending = this.pending.get(key); + if (!pending) return; + this.pending.delete(key); + + let queue: BoundedSubscriptionQueue | null = null; + const result = getRecord(value, 'result'); + const subscriptionId = result ? getString(result, 'subscriptionId') : null; + if (subscriptionId !== null) { + queue = new BoundedSubscriptionQueue(this.subscriptionQueueCapacity); + this.subscriptions.set(subscriptionId, queue); + } + pending.resolve({ line, queue }); + } + + /** + * Forwards one `event`/`subscription/closed` notification without waiting on a consumer. A full + * or abandoned local queue marks overflow, removes the registration, and — for a non-terminal + * notification — best-effort cancels the server-side subscription, mirroring + * crates/openengine-cluster-client/src/ndjson_pump.rs's `forward_notification`. + */ + private routeNotification(value: Record, line: string): void { + const params = getRecord(value, 'params'); + const subscriptionId = params ? getString(params, 'subscriptionId') : null; + if (subscriptionId === null) return; + const terminal = value.method === 'subscription/closed'; + const queue = this.subscriptions.get(subscriptionId); + if (!queue) return; + + const outcome = queue.tryPush(line); + if (outcome === 'ok') { + if (terminal) this.subscriptions.delete(subscriptionId); + return; + } + if (outcome === 'full') queue.markOverflowed(); + this.subscriptions.delete(subscriptionId); + if (!terminal) { + this.cancelSubscription(subscriptionId).catch(() => { + // Best-effort: the connection may already be gone. + }); + } + } + + /** Fails every still-pending request and ends every open subscription queue. */ + finish(): void { + for (const entry of this.pending.values()) { + entry.reject(new ClusterTransportError('connection closed before responding')); + } + this.pending.clear(); + for (const queue of this.subscriptions.values()) { + queue.end(); + } + this.subscriptions.clear(); + } + + /** Exposed for tests asserting AC4's "pending map is empty after a failed send" invariant. */ + get pendingSize(): number { + return this.pending.size; + } +} diff --git a/src/cluster/package.json b/src/cluster/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/src/cluster/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/cluster/subscription-stream.ts b/src/cluster/subscription-stream.ts new file mode 100644 index 00000000..bcdfc7c5 --- /dev/null +++ b/src/cluster/subscription-stream.ts @@ -0,0 +1,155 @@ +import { JSON_RPC_VERSION, parseUnaryResponseLine, type JsonRpcRequest, type RequestId } from './envelope.js'; +import { InvalidResponseError } from './errors.js'; +import { getRecord, getString, isRecord, parseJson } from './json-guards.js'; +import type { PumpedResponse } from './multiplexed-transport.js'; +import type { SubscriptionCloseReason } from './wire-types.generated.js'; + +/** + * What `watch` (via `watch-subscription.ts`) and the cursorless capabilities (`logs`, `agent/attach`, + * via this module's {@link establishEventSubscription}) need from a transport. Defined here rather + * than in `watch-subscription.ts` so that module can depend on this one (for + * {@link iterateUntilClosed}) without a type-level import cycle back the other way. + */ +export interface ClusterSubscriptionTransport { + sendRequest(serialized: string, id: RequestId): Promise; + nextWatchRequestId(): RequestId; + cancelSubscription(subscriptionId: string): Promise; +} + +/** + * Shared "one establishing unary response, then live `event`/`subscription/closed` notifications + * with no dedup or reconnect" subscription machinery for cursorless capabilities (`logs`, + * `agent/attach`). Generated once via {@link establishEventSubscription} rather than hand-copied per + * capability, mirroring crates/openengine-cluster-client/src/ndjson_subscription.rs's + * `impl_ndjson_event_subscription!` macro. `watch` has different (dedup + reconnect) semantics and + * is not built on this — see watch-subscription.ts. + */ +export type EventOrClosed = + | { readonly type: 'event'; readonly event: Event } + | { readonly type: 'closed'; readonly reason: SubscriptionCloseReason }; + +interface EventQueue { + recv(): Promise; + overflowed: boolean; +} + +function parseCursorlessNotification( + line: string, + extractEvent: (params: Record) => Event +): EventOrClosed { + const value: unknown = parseJson(line); + if (!isRecord(value)) throw new InvalidResponseError('subscription notification is not a JSON object'); + const params = getRecord(value, 'params'); + if (!params) throw new InvalidResponseError('subscription notification missing params'); + + if (value.method === 'event') { + return { type: 'event', event: extractEvent(params) }; + } + if (value.method === 'subscription/closed') { + const reason = getString(params, 'reason'); + if (reason !== 'done' && reason !== 'SLOW_CONSUMER') { + throw new InvalidResponseError(`unexpected subscription close reason: ${String(reason)}`); + } + return { type: 'closed', reason }; + } + throw new InvalidResponseError(`unexpected subscription notification method: ${String(value.method)}`); +} + +/** + * Cancel-once guard shared by every subscription stream (`CursorlessEventStream`, + * `WatchSubscriptionEventStream`): the first call sends `subscription/cancel`; every later call — + * including the implicit one from iterator `return()` — is a no-op. + */ +export function createCancelOnce( + transport: Pick, + subscriptionId: string +): () => Promise { + let cancelled = false; + return async () => { + if (cancelled) return; + cancelled = true; + await transport.cancelSubscription(subscriptionId); + }; +} + +/** + * Shared `for await` adapter: wraps a `next()`/`cancel()` pair — as independently implemented by + * {@link CursorlessEventStream}, `WatchSubscriptionEventStream`, and `DurableWatchClient` — into an + * `AsyncGenerator`. Breaking out of a `for await` loop (or calling `.return()` on the generator + * manually) resumes this `finally` block, so leaving the loop early calls `cancel` exactly once; + * each caller's own `cancel`/`close` is already idempotent, so this works whether cancellation is + * triggered by the loop or by an explicit call. + */ +export async function* iterateUntilClosed( + next: () => Promise, + cancel: () => Promise +): AsyncGenerator { + try { + for (;;) { + const outcome = await next(); + if (outcome === null) return; + yield outcome; + if (outcome.type === 'closed') return; + } + } finally { + await cancel(); + } +} + +/** Cursorless event stream: no dedup, no reconnect — `logs`/`agent/attach` have no cursor to resume from. */ +export class CursorlessEventStream { + private readonly queue: EventQueue; + private readonly extractEvent: (params: Record) => Event; + private readonly cancelOnce: () => Promise; + + constructor( + transport: ClusterSubscriptionTransport, + queue: EventQueue, + subscriptionId: string, + extractEvent: (params: Record) => Event + ) { + this.queue = queue; + this.extractEvent = extractEvent; + this.cancelOnce = createCancelOnce(transport, subscriptionId); + } + + /** Returns the next live event, a terminal close, or `null` once the channel ends. */ + async next(): Promise | null> { + const line = await this.queue.recv(); + if (line === null) { + if (this.queue.overflowed) { + this.queue.overflowed = false; + return { type: 'closed', reason: 'SLOW_CONSUMER' }; + } + return null; + } + return parseCursorlessNotification(line, this.extractEvent); + } + + /** Sends `subscription/cancel`. Idempotent: a second call (or a second `return()`) is a no-op. */ + cancel(): Promise { + return this.cancelOnce(); + } + + /** Makes this stream directly usable with `for await` — see {@link iterateUntilClosed}. */ + [Symbol.asyncIterator](): AsyncGenerator, void, void> { + return iterateUntilClosed(() => this.next(), () => this.cancel()); + } +} + +export async function establishEventSubscription( + transport: ClusterSubscriptionTransport, + method: string, + params: Params, + extractEvent: (params: Record) => Event +): Promise<{ result: Result; stream: CursorlessEventStream }> { + const id = transport.nextWatchRequestId(); + const request: JsonRpcRequest = { jsonrpc: JSON_RPC_VERSION, id, method, params }; + const response = await transport.sendRequest(JSON.stringify(request), id); + const result = parseUnaryResponseLine(response.line, id); + if (!response.queue) { + throw new InvalidResponseError(`a successful ${method} response must carry a subscriptionId`); + } + const stream = new CursorlessEventStream(transport, response.queue, result.subscriptionId, extractEvent); + return { result, stream }; +} diff --git a/src/cluster/watch-subscription.ts b/src/cluster/watch-subscription.ts new file mode 100644 index 00000000..b6024b49 --- /dev/null +++ b/src/cluster/watch-subscription.ts @@ -0,0 +1,218 @@ +import { JSON_RPC_VERSION, parseUnaryResponseLine, type JsonRpcRequest, type RequestId } from './envelope.js'; +import { InvalidResponseError } from './errors.js'; +import { getRecord, getString, isRecord, parseJson } from './json-guards.js'; +import { iterateUntilClosed, type ClusterSubscriptionTransport } from './subscription-stream.js'; +import type { SubscriptionCloseReason, WatchEvent, WatchParams, WatchResult } from './wire-types.generated.js'; + +export type WatchEventOrClosed = + | { readonly type: 'event'; readonly runId: string; readonly cursor: string; readonly event: WatchEvent } + | { + readonly type: 'closed'; + readonly reason: SubscriptionCloseReason; + readonly lastDeliveredCursor: string | null; + }; + +/** Deduplicates durable events by `(runId, cursor)`, mirroring `HashSet<(RunId, Cursor)>` in Rust. */ +export class RunCursorDedupSet { + private readonly seenCursorsByRunId = new Map>(); + + /** Returns `true` the first time this pair is seen, `false` for a legal redelivery to drop. */ + admit(runId: string, cursor: string): boolean { + let cursors = this.seenCursorsByRunId.get(runId); + if (!cursors) { + cursors = new Set(); + this.seenCursorsByRunId.set(runId, cursors); + } + if (cursors.has(cursor)) return false; + cursors.add(cursor); + return true; + } +} + +function parseWatchResponse(line: string, expectedId: RequestId): WatchResult { + return parseUnaryResponseLine(line, expectedId); +} + +function parseWatchNotification(line: string): WatchEventOrClosed { + const value: unknown = parseJson(line); + if (!isRecord(value)) throw new InvalidResponseError('subscription notification is not a JSON object'); + const method = value.method; + const params = getRecord(value, 'params'); + if (!params) throw new InvalidResponseError('subscription notification missing params'); + + if (method === 'event') { + const runId = getString(params, 'runId'); + const cursor = getString(params, 'cursor'); + const event = params.event; + if (runId === null || cursor === null || !isRecord(event)) { + throw new InvalidResponseError('event notification missing runId/cursor/event'); + } + // Trust the wire boundary for the event's variant shape, same as every other generated wire + // type at this transport layer — see envelope.ts's parseUnaryResponseLine. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { type: 'event', runId, cursor, event: event as unknown as WatchEvent }; + } + if (method === 'subscription/closed') { + const reason = getString(params, 'reason'); + if (reason !== 'done' && reason !== 'SLOW_CONSUMER') { + throw new InvalidResponseError(`unexpected subscription close reason: ${String(reason)}`); + } + return { type: 'closed', reason, lastDeliveredCursor: getString(params, 'lastDeliveredCursor') }; + } + throw new InvalidResponseError(`unexpected subscription notification method: ${String(method)}`); +} + +interface WatchStreamQueue { + recv(): Promise; + overflowed: boolean; +} + +interface WatchSubscriptionEventStreamInit { + readonly transport: ClusterSubscriptionTransport; + readonly queue: WatchStreamQueue; + readonly subscriptionId: string; + readonly dedup: RunCursorDedupSet; + readonly lastDelivered: string | null; + readonly runId: string | null; +} + +/** + * Deduplicates durable events by `(runId, cursor)` across legal at-least-once physical redelivery + * and across subscription-level reconnect (a fresh `watch` over the same live transport). Mirrors + * crates/openengine-cluster-client/src/ndjson_watch.rs's `WatchSubscriptionEventStream`. + */ +export class WatchSubscriptionEventStream { + private readonly transport: ClusterSubscriptionTransport; + private readonly queue: WatchStreamQueue; + private readonly subscriptionId: string; + private readonly dedup: RunCursorDedupSet; + private lastDelivered: string | null; + private runId: string | null; + private cancelled = false; + + constructor(init: WatchSubscriptionEventStreamInit) { + this.transport = init.transport; + this.queue = init.queue; + this.subscriptionId = init.subscriptionId; + this.dedup = init.dedup; + this.lastDelivered = init.lastDelivered; + this.runId = init.runId; + } + + /** @internal exposes the dedup set so `DurableWatchClient` can carry it across a full-socket reconnect. */ + dedupSet(): RunCursorDedupSet { + return this.dedup; + } + + lastDeliveredCursor(): string | null { + return this.lastDelivered; + } + + currentRunId(): string | null { + return this.runId; + } + + /** Returns the next logically new event, dropping legal duplicates, or a terminal close/`null`. */ + async next(): Promise { + for (;;) { + const line = await this.queue.recv(); + if (line === null) { + if (this.queue.overflowed) { + this.queue.overflowed = false; + return { type: 'closed', reason: 'SLOW_CONSUMER', lastDeliveredCursor: this.lastDelivered }; + } + return null; + } + const parsed = parseWatchNotification(line); + if (parsed.type === 'closed') { + if (parsed.lastDeliveredCursor !== null) this.lastDelivered = parsed.lastDeliveredCursor; + return parsed; + } + this.runId ??= parsed.runId; + if (!this.dedup.admit(parsed.runId, parsed.cursor)) continue; + this.lastDelivered = parsed.cursor; + return parsed; + } + } + + /** Sends `subscription/cancel`. Idempotent: a second call is a no-op. */ + async cancel(): Promise { + if (this.cancelled) return; + this.cancelled = true; + await this.transport.cancelSubscription(this.subscriptionId); + } + + /** Makes this stream directly usable with `for await` — see {@link iterateUntilClosed}. */ + [Symbol.asyncIterator](): AsyncGenerator { + return iterateUntilClosed(() => this.next(), () => this.cancel()); + } + + /** + * Re-establishes a subscription from this stream's last delivered cursor on the same run, over the + * SAME live transport (subscription-level reconnect — distinct from `DurableWatchClient`'s + * full-socket reconnect). The dedup set survives, so a duplicate delivered before and after + * reconnect is still suppressed once. + */ + reconnect(): Promise<{ result: WatchResult; stream: WatchSubscriptionEventStream }> { + return establishWatch(this.transport, { runId: this.runId, fromCursor: this.lastDelivered }, this.dedup); + } +} + +/** + * Establishes one `watch` subscription and wraps its response/queue into a + * {@link WatchSubscriptionEventStream} carrying `dedup`. Shared by `WatchSubscriptionClient.watch`/ + * `watchWithDedup` and `WatchSubscriptionEventStream.reconnect` so the latter never needs to + * reference the `WatchSubscriptionClient` class itself. + */ +async function establishWatch( + transport: ClusterSubscriptionTransport, + params: WatchParams, + dedup: RunCursorDedupSet +): Promise<{ result: WatchResult; stream: WatchSubscriptionEventStream }> { + const id = transport.nextWatchRequestId(); + const request: JsonRpcRequest = { + jsonrpc: JSON_RPC_VERSION, + id, + method: 'watch', + params, + }; + const response = await transport.sendRequest(JSON.stringify(request), id); + const result = parseWatchResponse(response.line, id); + if (!response.queue) { + throw new InvalidResponseError('a successful watch response must carry a subscriptionId'); + } + const stream = new WatchSubscriptionEventStream({ + transport, + queue: response.queue, + subscriptionId: result.subscriptionId, + dedup, + lastDelivered: params.fromCursor ?? null, + runId: result.runId ?? null, + }); + return { result, stream }; +} + +/** + * Typed watch subscription client. Mirrors + * crates/openengine-cluster-client/src/ndjson_watch.rs's `WatchSubscriptionClient`: request ids come + * from the shared transport (`transport.nextWatchRequestId()`), never a client-local counter. + */ +export class WatchSubscriptionClient { + private readonly transport: ClusterSubscriptionTransport; + + constructor(transport: ClusterSubscriptionTransport) { + this.transport = transport; + } + + watch(params: WatchParams): Promise<{ result: WatchResult; stream: WatchSubscriptionEventStream }> { + return establishWatch(this.transport, params, new RunCursorDedupSet()); + } + + /** Re-establishes a watch carrying an existing dedup set — used by {@link WatchSubscriptionEventStream.reconnect}. */ + watchWithDedup( + params: WatchParams, + dedup: RunCursorDedupSet + ): Promise<{ result: WatchResult; stream: WatchSubscriptionEventStream }> { + return establishWatch(this.transport, params, dedup); + } +} diff --git a/src/cluster/websocket-transport.ts b/src/cluster/websocket-transport.ts new file mode 100644 index 00000000..33e80fb6 --- /dev/null +++ b/src/cluster/websocket-transport.ts @@ -0,0 +1,202 @@ +import WS from 'ws'; + +import type { RequestId } from './envelope.js'; +import { ClusterTransportError } from './errors.js'; +import { isRecord } from './json-guards.js'; +import { + MultiplexedTransport, + type FrameSink, + type PumpedResponse, +} from './multiplexed-transport.js'; + +const READY_STATE_OPEN = 1; + +/** + * Minimal browser-`WebSocket`-compatible surface this transport depends on. Node's `ws` package + * satisfies this at runtime (it implements the same `addEventListener`/`send`/`close`/`readyState` + * contract as the browser standard), and any injected browser `WebSocket` constructor does too — + * this repo's tsconfig has no DOM lib, so this interface (not the global `WebSocket` type) is the + * only contract callers need to satisfy. + */ +export interface ClusterWebSocketLike { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: string, listener: (event: unknown) => void): void; +} + +export type WebSocketFactory = (url: string) => ClusterWebSocketLike; + +function wrapNodeWebSocket(socket: WS): ClusterWebSocketLike { + return { + get readyState(): number { + return socket.readyState; + }, + send(data: string): void { + socket.send(data); + }, + close(code?: number, reason?: string): void { + socket.close(code, reason); + }, + addEventListener(type: string, listener: (event: unknown) => void): void { + switch (type) { + case 'open': + socket.on('open', () => listener(undefined)); + break; + case 'close': + socket.on('close', (code, reason) => listener({ code, reason: reason.toString() })); + break; + case 'error': + socket.on('error', (error) => listener({ message: error.message })); + break; + case 'message': + socket.on('message', (data) => listener({ data: data.toString() })); + break; + default: + throw new ClusterTransportError(`unsupported WebSocket event type: ${type}`); + } + }, + }; +} + +function defaultNodeWebSocketFactory(url: string): ClusterWebSocketLike { + return wrapNodeWebSocket(new WS(url)); +} + +function resolveDefaultFactory(): WebSocketFactory { + const globalCtor = (globalThis as { WebSocket?: new (url: string) => ClusterWebSocketLike }) + .WebSocket; + if (typeof globalCtor === 'function') { + return (url: string) => new globalCtor(url); + } + return defaultNodeWebSocketFactory; +} + +function describeSocketEvent(event: unknown): string { + if (isRecord(event) && typeof event.message === 'string') return event.message; + if (isRecord(event) && typeof event.code === 'number') { + return `connection closed (code ${event.code})`; + } + return 'WebSocket connection failed'; +} + +function extractMessageText(event: unknown): string | null { + if (!isRecord(event)) return null; + const data = event.data; + return typeof data === 'string' ? data : null; +} + +export interface WebSocketTransportOptions { + readonly webSocketFactory?: WebSocketFactory; + readonly subscriptionQueueCapacity?: number | undefined; +} + +/** + * Production WebSocket binding of {@link MultiplexedTransport}: demultiplexes unary request/response + * traffic and generic `watch`/`logs`/`agent/attach` subscription notifications sharing one WebSocket + * connection. Mirrors crates/openengine-cluster-client/src/websocket.rs. + */ +export class WebSocketTransport { + private readonly socket: ClusterWebSocketLike; + private readonly multiplexed: MultiplexedTransport; + private closed = false; + private readonly closeListeners = new Set<() => void>(); + + private constructor(socket: ClusterWebSocketLike, multiplexed: MultiplexedTransport) { + this.socket = socket; + this.multiplexed = multiplexed; + } + + static async connect( + url: string, + options?: WebSocketTransportOptions + ): Promise { + const factory = options?.webSocketFactory ?? resolveDefaultFactory(); + const socket = factory(url); + const sink: FrameSink = { + sendFrame(frame: string): Promise { + if (socket.readyState !== READY_STATE_OPEN) { + return Promise.reject(new ClusterTransportError('WebSocket is not open')); + } + socket.send(frame); + return Promise.resolve(); + }, + }; + const multiplexed = new MultiplexedTransport(sink, { + subscriptionQueueCapacity: options?.subscriptionQueueCapacity, + }); + const transport = new WebSocketTransport(socket, multiplexed); + + await WebSocketTransport.waitForOpen(socket); + + socket.addEventListener('message', (event: unknown) => { + const text = extractMessageText(event); + if (text !== null) transport.multiplexed.routeIncoming(text); + }); + socket.addEventListener('close', () => transport.handleClose()); + + return transport; + } + + private static async waitForOpen(socket: ClusterWebSocketLike): Promise { + if (socket.readyState === READY_STATE_OPEN) return; + await new Promise((resolve, reject) => { + let settled = false; + socket.addEventListener('open', () => { + if (settled) return; + settled = true; + resolve(); + }); + const onFailure = (event: unknown): void => { + if (settled) return; + settled = true; + reject(new ClusterTransportError(describeSocketEvent(event))); + }; + socket.addEventListener('error', onFailure); + socket.addEventListener('close', onFailure); + }); + } + + private handleClose(): void { + if (this.closed) return; + this.closed = true; + this.multiplexed.finish(); + for (const listener of this.closeListeners) listener(); + } + + /** Registers a listener invoked exactly once when the underlying connection closes. */ + onClose(listener: () => void): void { + this.closeListeners.add(listener); + } + + get isClosed(): boolean { + return this.closed; + } + + close(): void { + if (this.closed) return; + this.socket.close(); + } + + sendRequest(serialized: string, id: RequestId): Promise { + return this.multiplexed.sendRequest(serialized, id); + } + + cancelSubscription(subscriptionId: string): Promise { + return this.multiplexed.cancelSubscription(subscriptionId); + } + + cancelRequest(id: RequestId): Promise { + return this.multiplexed.cancelRequest(id); + } + + /** @returns an integer request id, shared across every caller of this transport. */ + nextRequestId(): RequestId { + return this.multiplexed.nextRequestId(); + } + + /** @returns a `watch-` string request id, shared across every caller of this transport. */ + nextWatchRequestId(): RequestId { + return this.multiplexed.nextWatchRequestId(); + } +} diff --git a/src/cluster/wire-types.generated.ts b/src/cluster/wire-types.generated.ts new file mode 100644 index 00000000..862fc28e --- /dev/null +++ b/src/cluster/wire-types.generated.ts @@ -0,0 +1,1306 @@ +/** + * DO NOT EDIT. + * + * Generated by `npm run generate:cluster-protocol-types` from the authoritative protocol schemas: + * protocol/openengine-cluster/v1/schema.json + * protocol/openengine-cluster/v1/graph.schema.json + * protocol/openengine-cluster/v1/compiled-ir.schema.json + * protocol/openengine-cluster/v1/worker.schema.json + * + * Regenerate after any protocol schema change. `npm run check:cluster-protocol-types` fails CI on drift. + */ + +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "PayloadType". + */ +export type PayloadType = + | { + kind: 'null'; + } + | { + kind: 'boolean'; + } + | { + kind: 'integer'; + } + | { + kind: 'number'; + } + | { + kind: 'string'; + } + | { + fields: { + [k: string]: RecordField; + }; + kind: 'record'; + } + | { + items: PayloadType; + kind: 'array'; + } + | { + kind: 'enum'; + /** + * @minItems 1 + * @maxItems 4096 + */ + values: [string, ...string[]]; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "PolicyDefault". + */ +export type PolicyDefault = 'deny'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphProfile". + */ +export type GraphProfile = 'openengine.graph.full/v1' | 'openengine.graph.single-worker/v1'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphNode". + */ +export type GraphNode = + | { + attempts: number; + input: PayloadType; + inputBindings: InputBinding[]; + kind: 'step'; + name: string; + output: PayloadType; + timeoutMs: number; + worker: string; + writeBindings: WriteBinding[]; + } + | { + attempts: number; + diagnostic: PayloadType; + input: PayloadType; + inputBindings: InputBinding[]; + kind: 'verifier'; + name: string; + output: PayloadType; + signals: { + /** + * @minItems 1 + * @maxItems 4096 + */ + [k: string]: [string, ...string[]]; + }; + timeoutMs: number; + worker: string; + writeBindings: WriteBinding[]; + } + | { + children: NonEmptyVecOf_GraphNode; + kind: 'seq'; + name: string; + promotedStatePaths: [string, ...string[]][]; + state: PayloadType; + } + | { + branches: NonEmptyVecOf_ChoiceBranch; + kind: 'choice'; + name: string; + otherwise?: GraphNode | null; + promotedStatePaths: [string, ...string[]][]; + state: PayloadType; + } + | { + branches: NonEmptyVecOf_GraphNode; + join: Join; + kind: 'par'; + name: string; + promotedStatePaths: [string, ...string[]][]; + state: PayloadType; + } + | { + body: GraphNode; + kind: 'loop'; + maxIterations: number; + name: string; + promotedStatePaths: [string, ...string[]][]; + state: PayloadType; + until: Guard; + } + | { + body: GraphNode; + kind: 'map'; + maxItems: number; + name: string; + over: DataSelector; + promotedStatePaths: [string, ...string[]][]; + state: PayloadType; + } + | { + bindings: InputBinding[]; + kind: 'succeed'; + name: string; + output: PayloadType; + } + | { + kind: 'fail'; + name: string; + reason: string; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DataSelector". + */ +export type DataSelector = + | { + /** + * @minItems 1 + * @maxItems 64 + */ + path: [string, ...string[]]; + source: 'state'; + } + | { + /** + * @minItems 1 + * @maxItems 64 + */ + path: [string, ...string[]]; + source: 'item'; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NodeOutputChannel". + */ +export type NodeOutputChannel = 'out' | 'signal' | 'diagnostic'; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_GraphNode". + */ +export type NonEmptyVecOf_GraphNode = [GraphNode, ...GraphNode[]]; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_ChoiceBranch". + */ +export type NonEmptyVecOf_ChoiceBranch = [ChoiceBranch, ...ChoiceBranch[]]; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "Guard". + */ +export type Guard = + | { + kind: 'in'; + /** + * @minItems 1 + * @maxItems 4096 + */ + labels: [string, ...string[]]; + value: ControlSelector; + } + | { + guards: NonEmptyVecOf_Guard; + kind: 'all'; + } + | { + guards: NonEmptyVecOf_Guard; + kind: 'any'; + } + | { + guard: Guard; + kind: 'not'; + } + | { + count: number; + kind: 'k_of_n'; + /** + * @minItems 1 + * @maxItems 4096 + */ + labels: [string, ...string[]]; + values: NonEmptyVecOf_ControlSelector; + } + | { + count: number; + kind: 'k_of_map'; + /** + * @minItems 1 + * @maxItems 4096 + */ + labels: [string, ...string[]]; + value: ControlSelector; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ControlSource". + */ +export type ControlSource = 'signal' | 'error' | 'group'; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_Guard". + */ +export type NonEmptyVecOf_Guard = [Guard, ...Guard[]]; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_ControlSelector". + */ +export type NonEmptyVecOf_ControlSelector = [ControlSelector, ...ControlSelector[]]; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "Join". + */ +export type Join = + | { + kind: 'all'; + } + | { + kind: 'any'; + } + | { + count: number; + kind: 'quorum'; + } + | { + kind: 'first'; + when: Guard; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "SubscriptionCloseReason". + */ +export type SubscriptionCloseReason = 'done' | 'SLOW_CONSUMER'; +/** + * The closed public agent-attach progress algebra. This is the only representable shape: + * reasoning, tools, provider frames, usage, and session identifiers have no variant. `Working` + * and `Settled` are empty struct variants rather than bare units: serde's internally tagged enum + * deserialization silently ignores unknown fields on a unit variant regardless of + * `deny_unknown_fields`, which would otherwise let an unrepresentable field ride along + * undetected on either of these two variants. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachEvent". + */ +export type AgentAttachEvent = + | { + type: 'working'; + } + | { + text: string; + type: 'output'; + } + | { + type: 'settled'; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "Phase". + */ +export type Phase = 'empty' | 'admitting' | 'running' | 'finished' | 'deleting'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "RedactionClass". + */ +export type RedactionClass = 'public' | 'internal' | 'confidential' | 'restricted'; +/** + * Descriptive only, like [`FaultRetryDisposition`]: naming `Retry` never itself retries or + * authorizes a retry. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultAction". + */ +export type FaultAction = 'none' | 'retry' | 'wait' | 'escalate' | 'abort'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultCode". + */ +export type FaultCode = + | 'unavailable' + | 'resource_exhausted' + | 'deadline_exceeded' + | 'permission_denied' + | 'failed_precondition' + | 'not_found' + | 'aborted' + | 'internal' + | 'unknown'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultConsequence". + */ +export type FaultConsequence = + | 'turn_failed' + | 'run_failed' + | 'run_degraded' + | 'no_observable_effect'; +/** + * Descriptive only: no `BackendFault` and no `fault` event ever performs or authorizes a retry. + * Event ordering and emission never themselves change terminal semantics. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultRetryDisposition". + */ +export type FaultRetryDisposition = + | 'retryable' + | 'retryable_after_backoff' + | 'not_retryable' + | 'indeterminate'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultSeverity". + */ +export type FaultSeverity = 'info' | 'warning' | 'error' | 'critical'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "RequestId". + */ +export type RequestId = string | number; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DispatchState". + */ +export type DispatchState = 'active' | 'suspended' | 'draining' | 'force_stopping' | 'stopped'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogLevel". + */ +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "StopMode". + */ +export type StopMode = 'drain' | 'force'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DiagnosticPathSegment". + */ +export type DiagnosticPathSegment = + | { + kind: 'field'; + name: string; + } + | { + index: number; + kind: 'index'; + } + | { + kind: 'node'; + name: string; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DiagnosticSeverity". + */ +export type DiagnosticSeverity = 'error' | 'warning' | 'info'; +/** + * The closed public event algebra. `Phase` folds the observable cluster status (admission + * commit, update, suspend/resume, stop-request); `NodeBegin`/`NodeEnd` are a testkit-only + * synthetic hook decoupled from the real dispatch/lease turn mechanism, since no native graph + * executor exists yet; `Bookmark` advances the cursor without changing folded public state; + * `Fault` carries a durable, backend-neutral projected `BackendFault`: it correlates to the + * enclosing `EventNotification.run_id` plus its own optional opaque `executionRef`, and its + * ordering/emission never itself authorizes a retry or changes terminal semantics -- it folds to + * no public status change, exactly like `Bookmark`; `Finished` is always the last event for a + * run. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WatchEvent". + */ +export type WatchEvent = + | { + admission?: AdmissionTransition | null; + status: ClusterStatus; + type: 'phase'; + } + | { + input: unknown; + node: NodeAddress; + type: 'node_begin'; + } + | { + node: NodeAddress; + outcome: WorkerOutcome; + type: 'node_end'; + } + | { + type: 'bookmark'; + } + | { + fault: BackendFault; + type: 'fault'; + } + | { + final_status: ClusterStatus; + stop_mode?: StopMode | null; + type: 'finished'; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerOutcome". + */ +export type WorkerOutcome = WorkerOutcomeWire & { + [k: string]: unknown; +}; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerOutcomeWire". + */ +export type WorkerOutcomeWire = + | { + artifacts: ArtifactRef[]; + output: unknown; + status: 'verified'; + } + | { + artifacts: ArtifactRef[]; + diagnostic: unknown; + output: unknown; + signals: { + [k: string]: string; + }; + status: 'verifier'; + } + | { + code: WorkerErrorCode; + reason: WorkerFailureReason; + status: 'error'; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerErrorCode". + */ +export type WorkerErrorCode = 'timeout' | 'crash' | 'malformed' | 'refusal'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerFailureReason". + */ +export type WorkerFailureReason = + | 'declared_failure' + | 'policy_denied' + | 'interactive_input_required' + | 'authentication_required' + | 'malformed_result'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphDiagnosticCode". + */ +export type GraphDiagnosticCode = + | 'schema_safety' + | 'reachability' + | 'choice_exhaustiveness' + | 'loop_exit_satisfiability' + | 'missing_bound' + | 'write_conflict' + | 'ceiling_exceeded' + | 'cyclic_reference' + | 'undefined_read' + | 'invalid_graph_shape'; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_FieldPath". + */ +export type NonEmptyVecOf_FieldPath = [[string, ...string[]], ...[string, ...string[]][]]; +/** + * @minItems 1 + * @maxItems 4096 + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NonEmptyVec_of_NodeName". + */ +export type NonEmptyVecOf_NodeName = [string, ...string[]]; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "TerminationWitness". + */ +export type TerminationWitness = + | { + kind: 'acyclic'; + order: NonEmptyVecOf_NodeName; + } + | { + kind: 'bounded'; + maxIterations: number; + ranking: NonEmptyVecOf_FieldPath; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AutonomyPolicy". + */ +export type AutonomyPolicy = 'strict'; +/** + * Opaque registry identity. The handle can select secret material but can never contain it. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "CredentialHandle". + */ +export type CredentialHandle = string; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LegacyShipRequest". + */ +export type LegacyShipRequest = + | { + artifacts?: { + [k: string]: unknown; + }; + issue: string; + prompt?: null; + source?: 'issue'; + [k: string]: unknown; + } + | { + artifacts?: { + [k: string]: unknown; + }; + issue?: null; + prompt: string; + source?: 'prompt'; + [k: string]: unknown; + } + | { + artifacts?: { + [k: string]: unknown; + }; + issue?: null; + prompt?: null; + source?: 'artifact'; + [k: string]: unknown; + }; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LegacyShipStatus". + */ +export type LegacyShipStatus = 'succeeded' | 'failed'; +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerProtocolBinding". + */ +export type WorkerProtocolBinding = + | { + profile: 'openengine.worker.acp/v1'; + protocol: 'acp'; + version: '1'; + } + | { + profile: 'openengine.worker.a2a/1.0'; + protocol: 'a2a'; + version: '1.0'; + } + | { + profile: 'legacy.zeroshot.ship/v1'; + protocol: 'legacy_zeroshot'; + version: '1'; + } + | { + profile: 'openengine.worker.builtin/v1'; + protocol: 'builtin'; + version: '1'; + }; + +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AdmissionTransition". + */ +export interface AdmissionTransition { + runId: string; + seedInput: unknown; + spec: GraphSpec; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphSpec". + */ +export interface GraphSpec { + initialInput: PayloadType; + policy: PolicyBinding; + profile: GraphProfile; + root: GraphNode; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "RecordField". + */ +export interface RecordField { + required: boolean; + type: PayloadType; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "PolicyBinding". + */ +export interface PolicyBinding { + default: PolicyDefault; + policy: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "InputBinding". + */ +export interface InputBinding { + /** + * @minItems 1 + * @maxItems 64 + */ + target: [string, ...string[]]; + value: DataSelector; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WriteBinding". + */ +export interface WriteBinding { + /** + * @minItems 1 + * @maxItems 64 + */ + target: [string, ...string[]]; + value: NodeOutputSelector; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NodeOutputSelector". + */ +export interface NodeOutputSelector { + channel: NodeOutputChannel; + node: string; + /** + * @minItems 1 + * @maxItems 64 + */ + path: [string, ...string[]]; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ChoiceBranch". + */ +export interface ChoiceBranch { + node: GraphNode; + when: Guard; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ControlSelector". + */ +export interface ControlSelector { + field?: string | null; + name: string; + source: ControlSource; +} +/** + * Wire body of the terminal `subscription/closed` server notification for an `agent/attach` + * subscription. Deliberately carries no cursor field -- `agent/attach` gives a type-level + * "cursorless" guarantee, unlike [`crate::SubscriptionClosedNotification`]. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachClosedNotification". + */ +export interface AgentAttachClosedNotification { + reason: SubscriptionCloseReason; + subscriptionId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachEventNotification". + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachEventNotificationWire". + */ +export interface AgentAttachEventNotificationWire { + event: AgentAttachEvent; + subscriptionId: string; +} +/** + * `agent/attach` establishment parameters: the named `{execution}` request. Deliberately closed, + * rejecting any unknown field. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachParams". + */ +export interface AgentAttachParams { + execution: string; +} +/** + * The `agent/attach` establishment result: only a `subscriptionId`. Deliberately carries no + * `runId` or `atCursor` -- `agent/attach` is not run-scoped and has no cursor. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "AgentAttachResult". + */ +export interface AgentAttachResult { + subscriptionId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ApplyParams". + */ +export interface ApplyParams { + dryRun?: boolean; + graph: GraphSpec; + idempotencyKey?: string; + ifGeneration?: number; + input?: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ApplyResult". + */ +export interface ApplyResult { + deduped: boolean; + diff?: GraphDiff | null; + generation?: number | null; + phase: Phase; + runId?: string | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphDiff". + */ +export interface GraphDiff { + added: string[]; + changed: string[]; + removed: string[]; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ArtifactLineage". + */ +export interface ArtifactLineage { + attempt: number; + generation: number; + runId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ArtifactProducer". + */ +export interface ArtifactProducer { + node: string; + worker: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ArtifactRef". + */ +export interface ArtifactRef { + artifactId: string; + byteLength: number; + lineage: ArtifactLineage; + mediaType: string; + producer: ArtifactProducer; + redaction: RedactionClass; + sha256: string; + typeId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "BackendFault". + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "BackendFaultWire". + */ +export interface BackendFault { + action: FaultAction; + code: FaultCode; + consequence: FaultConsequence; + eventId: string; + executionRef?: string | null; + retry: FaultRetryDisposition; + severity: FaultSeverity; + /** + * @maxItems 8 + */ + source: + | [] + | [FaultSourceFrame] + | [FaultSourceFrame, FaultSourceFrame] + | [FaultSourceFrame, FaultSourceFrame, FaultSourceFrame] + | [FaultSourceFrame, FaultSourceFrame, FaultSourceFrame, FaultSourceFrame] + | [FaultSourceFrame, FaultSourceFrame, FaultSourceFrame, FaultSourceFrame, FaultSourceFrame] + | [ + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame + ] + | [ + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame + ] + | [ + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame, + FaultSourceFrame + ]; + summary: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "FaultSourceFrame". + */ +export interface FaultSourceFrame { + component: string; +} +/** + * Wire body of the `$/cancelRequest` client notification: best-effort cancellation of an + * in-flight unary request by its `RequestId`. Unknown or already-completed ids are a silent + * no-op; cancelling after the backend has committed leaves that committed state unchanged and + * emits no response or compensation. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "CancelRequestParams". + */ +export interface CancelRequestParams { + id: RequestId; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ClusterStatus". + */ +export interface ClusterStatus { + atCursor?: string | null; + currentRunId?: string | null; + observedGeneration?: number | null; + operational?: OperationalStatus | null; + phase: Phase; + [k: string]: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "OperationalStatus". + */ +export interface OperationalStatus { + dispatchState: DispatchState; + inFlight: number; + labels: Labels; + logLevel: LogLevel; + stopMode?: StopMode | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "Labels". + */ +export interface Labels { + [k: string]: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DeleteParams". + */ +export interface DeleteParams { + idempotencyKey: string; + ifGeneration: number; + ifRunId?: string | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DeleteResult". + */ +export interface DeleteResult { + atCursor?: string | null; + deduped: boolean; + deleted: boolean; + generation?: number | null; + phase: Phase; + runId?: string | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "DomainErrorData". + */ +export interface DomainErrorData { + code: string; + details?: unknown; + [k: string]: unknown; +} +/** + * Wire body of the generic `event` server notification. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "EventNotification". + */ +export interface EventNotification { + cursor: string; + event: WatchEvent; + runId: string; + subscriptionId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "NodeAddress". + */ +export interface NodeAddress { + attempt: number; + node: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GetParams". + */ +export interface GetParams { + atCursor?: string | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GetResult". + */ +export interface GetResult { + atCursor?: string | null; + spec?: GraphSpec | null; + status: ClusterStatus; + [k: string]: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "GraphDiagnostic". + */ +export interface GraphDiagnostic { + code: GraphDiagnosticCode; + message: string; + path: DiagnosticPathSegment[]; + relatedNodes: string[]; + severity: DiagnosticSeverity; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "InitializeParams". + */ +export interface InitializeParams { + protocolVersion: 'openengine.cluster/v1'; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "InitializeResult". + */ +export interface InitializeResult { + capabilities: ServerCapabilities; + protocolVersion: 'openengine.cluster/v1'; + status: ClusterStatus; + [k: string]: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ServerCapabilities". + */ +export interface ServerCapabilities { + agentAttach?: boolean; + /** + * @maxItems 2 + */ + graphProfiles?: [] | [GraphProfile] | [GraphProfile, GraphProfile]; + logs?: boolean; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogEventNotification". + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogEventNotificationWire". + */ +export interface LogEventNotificationWire { + record: LogRecord; + subscriptionId: string; +} +/** + * The closed public log record shape: a level, a bounded target, and a bounded (possibly + * redacted) message. No raw bytes, reasoning, tools, credentials, env, or provider/session IDs + * are representable. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogRecord". + */ +export interface LogRecord { + level: LogLevel; + message: string; + target: string; +} +/** + * Wire body of the terminal `subscription/closed` server notification for a `logs` subscription. + * Deliberately carries no cursor field -- `logs` gives a type-level "cursorless" guarantee, unlike + * [`crate::SubscriptionClosedNotification`]. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogsClosedNotification". + */ +export interface LogsClosedNotification { + reason: SubscriptionCloseReason; + subscriptionId: string; +} +/** + * `logs` establishment parameters. v1 has zero caller filters: this is deliberately empty and + * closed, rejecting any unknown field. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogsParams". + */ +export interface LogsParams {} +/** + * The `logs` establishment result: only a `subscriptionId`. Deliberately carries no `runId` or + * `atCursor` -- `logs` is not run-scoped and has no cursor. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LogsResult". + */ +export interface LogsResult { + subscriptionId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "PlanParams". + */ +export interface PlanParams { + graph: GraphSpec; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "PlanResult". + */ +export interface PlanResult { + bounds?: StructuralBounds | null; + diagnostics: GraphDiagnostic[]; + ok: boolean; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "StructuralBounds". + */ +export interface StructuralBounds { + attemptsPerNode: { + [k: string]: number; + }; + maxNodeExecutions: number; + peakConcurrency: number; + termination: TerminationWitness; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ResubmitParams". + */ +export interface ResubmitParams { + idempotencyKey: string; + ifGeneration: number; + ifRunId: string; + replacementInput?: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ResubmitResult". + */ +export interface ResubmitResult { + atCursor: string; + deduped: boolean; + generation: number; + operational: OperationalStatus; + phase: Phase; + priorRunId: string; + runId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "RetryParams". + */ +export interface RetryParams { + idempotencyKey: string; + ifGeneration: number; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "RetryResult". + */ +export interface RetryResult { + atCursor: string; + deduped: boolean; + generation: number; + operational: OperationalStatus; + phase: Phase; + retriedTurnId: string; + retryTurnId: string; + runId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "StopParams". + */ +export interface StopParams { + idempotencyKey: string; + ifGeneration: number; + mode: StopMode; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "StopResult". + */ +export interface StopResult { + acceptedMode: StopMode; + atCursor: string; + deduped: boolean; + effectiveMode: StopMode; + generation: number; + operational: OperationalStatus; + phase: Phase; + runId: string; +} +/** + * Wire body of the generic `subscription/cancel` client notification. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "SubscriptionCancelParams". + */ +export interface SubscriptionCancelParams { + subscriptionId: string; +} +/** + * Wire body of the terminal `subscription/closed` server notification. + * + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "SubscriptionClosedNotification". + */ +export interface SubscriptionClosedNotification { + lastDeliveredCursor?: string | null; + reason: SubscriptionCloseReason; + subscriptionId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "UpdateParams". + */ +export interface UpdateParams { + idempotencyKey: string; + ifGeneration: number; + labels?: Labels; + logLevel?: LogLevel; + suspended?: boolean; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "UpdateResult". + */ +export interface UpdateResult { + atCursor: string; + deduped: boolean; + generation: number; + operational: OperationalStatus; + phase: Phase; + runId: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WatchParams". + */ +export interface WatchParams { + fromCursor?: string | null; + runId?: string | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WatchResult". + */ +export interface WatchResult { + atCursor?: string | null; + runId?: string | null; + subscriptionId: string; + [k: string]: unknown; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "ArtifactResultProfile". + */ +export interface ArtifactResultProfile { + /** + * @minItems 1 + */ + allowedMediaTypes: [string, ...string[]]; + /** + * @minItems 1 + */ + allowedTypeIds: [string, ...string[]]; + minimumRedaction: RedactionClass; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "CapabilityPolicy". + */ +export interface CapabilityPolicy { + autonomy: AutonomyPolicy; + permissionPolicy: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "LegacyShipResult". + */ +export interface LegacyShipResult { + artifacts: ArtifactRef[]; + status: LegacyShipStatus; + summary: string; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "VerifierContract". + */ +export interface VerifierContract { + diagnostic: PayloadType; + signals: { + /** + * @minItems 1 + * @maxItems 4096 + */ + [k: string]: [string, ...string[]]; + }; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerContract". + */ +export interface WorkerContract { + /** + * @minItems 4 + * @maxItems 4 + */ + errors: [ + 'timeout' | 'crash' | 'malformed' | 'refusal', + 'timeout' | 'crash' | 'malformed' | 'refusal', + 'timeout' | 'crash' | 'malformed' | 'refusal', + 'timeout' | 'crash' | 'malformed' | 'refusal' + ]; + input: PayloadType; + output: PayloadType; + verifier?: VerifierContract | null; +} +/** + * This interface was referenced by `ClusterWireTypesRoot`'s JSON-Schema + * via the `definition` "WorkerDescriptorWire". + */ +export interface WorkerDescriptorWire { + artifactProfile: ArtifactResultProfile; + binding: WorkerProtocolBinding; + capabilityPolicy: CapabilityPolicy; + contract: WorkerContract; + credentialRequirements: CredentialHandle[]; + /** + * @minItems 1 + */ + graphProfiles: [GraphProfile, ...GraphProfile[]]; + worker: string; +} diff --git a/tests/cluster-client/_fake-websocket.js b/tests/cluster-client/_fake-websocket.js new file mode 100644 index 00000000..eba18163 --- /dev/null +++ b/tests/cluster-client/_fake-websocket.js @@ -0,0 +1,76 @@ +'use strict'; + +const READY_STATE_CONNECTING = 0; +const READY_STATE_OPEN = 1; +const READY_STATE_CLOSED = 3; + +/** + * Minimal `ClusterWebSocketLike`-compatible fake (see src/cluster/websocket-transport.ts) driven + * entirely by a scripted `respond(request, socket)` callback: `{ reply, after }`, where `reply` is + * the JSON-RPC response/notification object to deliver and `after(socket)` runs once `reply` has + * been routed (so it can push follow-up notifications, e.g. simulated `event` traffic, only after + * the establishing response registered the subscription). + */ +class FakeWebSocket { + constructor(url, respond) { + this.url = url; + this.readyState = READY_STATE_CONNECTING; + this.sent = []; + this._listeners = { open: [], close: [], error: [], message: [] }; + this._respond = respond || null; + queueMicrotask(() => { + if (this.readyState === READY_STATE_CLOSED) return; + this.readyState = READY_STATE_OPEN; + this._emit('open', undefined); + }); + } + + send(data) { + this.sent.push(data); + if (!this._respond) return; + const request = JSON.parse(data); + const outcome = this._respond(request, this); + if (!outcome) return; + const { reply, after } = outcome; + queueMicrotask(() => { + if (reply) this._emit('message', { data: JSON.stringify(reply) }); + if (after) queueMicrotask(() => after(this)); + }); + } + + close() { + if (this.readyState === READY_STATE_CLOSED) return; + this.readyState = READY_STATE_CLOSED; + queueMicrotask(() => this._emit('close', { code: 1000, reason: '' })); + } + + addEventListener(type, listener) { + this._listeners[type].push(listener); + } + + _emit(type, event) { + for (const listener of this._listeners[type].slice()) listener(event); + } + + /** Test-only: deliver an unsolicited server->client frame (e.g. a subscription `event`). */ + push(message) { + queueMicrotask(() => this._emit('message', { data: JSON.stringify(message) })); + } + + /** Test-only: simulate the connection dying (server crash, network partition, etc). */ + simulateDisconnect() { + this.close(); + } +} + +function createWebSocketFactory(respond) { + const sockets = []; + const factory = (url) => { + const socket = new FakeWebSocket(url, respond); + sockets.push(socket); + return socket; + }; + return { factory, sockets }; +} + +module.exports = { FakeWebSocket, createWebSocketFactory, READY_STATE_OPEN }; diff --git a/tests/cluster-client/_fixtures.js b/tests/cluster-client/_fixtures.js new file mode 100644 index 00000000..b7bad181 --- /dev/null +++ b/tests/cluster-client/_fixtures.js @@ -0,0 +1,63 @@ +'use strict'; + +const { MultiplexedTransport } = require('../../lib/cluster/cjs/index.js'); + +/** In-memory `FrameSink` capturing every sent frame; `sendFrame` can be scripted to fail once. */ +class FakeSink { + constructor() { + this.frames = []; + this._failNextCount = 0; + } + + /** The next `sendFrame` calls (up to `count`) reject instead of recording the frame. */ + failNextSends(count = 1) { + this._failNextCount = count; + } + + sendFrame(frame) { + if (this._failNextCount > 0) { + this._failNextCount -= 1; + return Promise.reject(new Error('WebSocket is not open')); + } + this.frames.push(frame); + return Promise.resolve(); + } +} + +/** A `MultiplexedTransport` over a `FakeSink`, with a helper to feed lines back in as if received. */ +function createHarness(options) { + const sink = new FakeSink(); + const transport = new MultiplexedTransport(sink, options); + return { sink, transport }; +} + +function parseFrame(frame) { + return JSON.parse(frame); +} + +/** Builds a JSON-RPC success response line replying to the given request frame. */ +function successReplyFor(frame, result) { + const request = parseFrame(frame); + return JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); +} + +function eventNotification(subscriptionId, params) { + return JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { subscriptionId, ...params } }); +} + +function closedNotification(subscriptionId, reason, extra) { + return JSON.stringify({ + jsonrpc: '2.0', + method: 'subscription/closed', + params: { subscriptionId, reason, ...extra }, + }); +} + +module.exports = { + FakeSink, + createHarness, + parseFrame, + successReplyFor, + eventNotification, + closedNotification, +}; diff --git a/tests/cluster-client/_subscription-contract.js b/tests/cluster-client/_subscription-contract.js new file mode 100644 index 00000000..afb7be95 --- /dev/null +++ b/tests/cluster-client/_subscription-contract.js @@ -0,0 +1,112 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { parseFrame, successReplyFor, eventNotification } = require('./_fixtures.js'); +const { CapabilityNotSupportedError } = require('../../lib/cluster/cjs/index.js'); + +/** + * Asserts the capability-gate half of the contract shared by cursorless subscriptions (`logs`, + * `agent/attach`): calling the method with its capability disabled throws before any frame is sent. + * Callers wrap this in their own `test(...)` so each file keeps its own test registration. + */ +function assertCapabilityGateThrows({ createHarness, createClient, invoke }) { + const { transport, sink } = createHarness(); + const client = createClient(transport); + + assert.throws(() => invoke(client, false), CapabilityNotSupportedError); + assert.equal(sink.frames.length, 0, 'a capability-gated rejection must not send any frame'); +} + +/** + * Asserts the round-trip half of the contract shared by cursorless subscriptions (`logs`, + * `agent/attach`): calling the method with its capability enabled sends `wireMethod`, then + * delivers a first event through the client's typed stream. Callers wrap this in their own + * `test(...)` so each file keeps its own test registration. + */ +async function assertCapabilityGatedFirstEvent({ + createHarness, + createClient, + invoke, + wireMethod, + assertRequestParams, + firstEventParams, + assertFirstEvent, +}) { + const { transport, sink } = createHarness(); + const client = createClient(transport); + + const promise = invoke(client, true); + const requestFrame = sink.frames.at(-1); + const request = parseFrame(requestFrame); + assert.equal(request.method, wireMethod); + assertRequestParams?.(request.params); + + transport.routeIncoming(successReplyFor(requestFrame, { subscriptionId: 'sub-1' })); + const { result, stream } = await promise; + assert.equal(result.subscriptionId, 'sub-1'); + + transport.routeIncoming(eventNotification('sub-1', firstEventParams)); + const outcome = await stream.next(); + assert.equal(outcome.type, 'event'); + assertFirstEvent(outcome.event); +} + +/** + * The subscription-lifecycle contract shared by `logs`/`agent-attach`/`watch` (AC5's local + * overflow -> exactly one SLOW_CONSUMER close, and AC6's iterator-return -> exactly one cancel). + * `establish(harness)` opens a subscription and returns `{ stream, subscriptionId }`. + * `pushEvent(harness, subscriptionId, n)` delivers one distinguishable `event` notification. + */ +function describeSubscriptionContract(label, { createHarness, establish, pushEvent }) { + test(`${label}: local overflow surfaces exactly one SLOW_CONSUMER close then ends`, async () => { + const harness = createHarness({ subscriptionQueueCapacity: 1 }); + const { stream, subscriptionId } = await establish(harness); + pushEvent(harness, subscriptionId, 0); + pushEvent(harness, subscriptionId, 1); + + const first = await stream.next(); + assert.equal(first.type, 'event'); + const closed = await stream.next(); + assert.equal(closed.type, 'closed'); + assert.equal(closed.reason, 'SLOW_CONSUMER'); + assert.equal( + await stream.next(), + null, + 'iteration must end after the single SLOW_CONSUMER close' + ); + + const cancelSent = harness.sink.frames.some((frame) => { + const parsed = parseFrame(frame); + return ( + parsed.method === 'subscription/cancel' && parsed.params.subscriptionId === subscriptionId + ); + }); + assert.ok(cancelSent, 'overflow must best-effort cancel the server-side subscription'); + }); + + test(`${label}: breaking a for-await loop cancels the subscription exactly once`, async () => { + const harness = createHarness(); + const { stream, subscriptionId } = await establish(harness); + pushEvent(harness, subscriptionId, 0); + + // eslint-disable-next-line no-unreachable-loop -- intentional single-pass break: exercises AC6's async-iterator return() cleanup + for await (const outcome of stream) { + assert.equal(outcome.type, 'event'); + break; + } + await stream.cancel(); // idempotent: the implicit iterator-return cancel already fired + + const cancelFrames = harness.sink.frames.filter( + (frame) => parseFrame(frame).method === 'subscription/cancel' + ); + assert.equal(cancelFrames.length, 1); + }); +} + +module.exports = { + assertCapabilityGateThrows, + assertCapabilityGatedFirstEvent, + describeSubscriptionContract, +}; diff --git a/tests/cluster-client/agent-attach.test.js b/tests/cluster-client/agent-attach.test.js new file mode 100644 index 00000000..fc00cd80 --- /dev/null +++ b/tests/cluster-client/agent-attach.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { AgentAttachSubscriptionClient } = require('../../lib/cluster/cjs/index.js'); +const { createHarness, successReplyFor, eventNotification } = require('./_fixtures.js'); +const { + assertCapabilityGateThrows, + assertCapabilityGatedFirstEvent, + describeSubscriptionContract, +} = require('./_subscription-contract.js'); + +const createClient = (transport) => new AgentAttachSubscriptionClient(transport); +const invoke = (client, enabled) => + client.agentAttach({ execution: 'exec-1' }, { agentAttach: enabled }); + +test('agentAttach() throws before opening any connection when capabilities.agentAttach is false', () => { + assertCapabilityGateThrows({ createHarness, createClient, invoke }); +}); + +test('agentAttach() delivers a first event for a capability-advertising server, cursorless', async () => { + await assertCapabilityGatedFirstEvent({ + createHarness, + createClient, + invoke, + wireMethod: 'agent/attach', + assertRequestParams: (params) => assert.equal(params.execution, 'exec-1'), + firstEventParams: { event: { type: 'working' } }, + assertFirstEvent: (event) => assert.deepEqual(event, { type: 'working' }), + }); +}); + +describeSubscriptionContract('agent/attach', { + createHarness, + async establish(harness) { + const client = new AgentAttachSubscriptionClient(harness.transport); + const promise = client.agentAttach({ execution: 'exec-1' }, { agentAttach: true }); + const requestFrame = harness.sink.frames.at(-1); + harness.transport.routeIncoming(successReplyFor(requestFrame, { subscriptionId: 'sub-1' })); + const { stream } = await promise; + return { stream, subscriptionId: 'sub-1' }; + }, + pushEvent(harness, subscriptionId) { + harness.transport.routeIncoming( + eventNotification(subscriptionId, { event: { type: 'working' } }) + ); + }, +}); diff --git a/tests/cluster-client/cluster-client.test.js b/tests/cluster-client/cluster-client.test.js new file mode 100644 index 00000000..b2584136 --- /dev/null +++ b/tests/cluster-client/cluster-client.test.js @@ -0,0 +1,135 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + ClusterClient, + RpcError, + AbortError, + InvalidResponseError, + PROTOCOL_VERSION, +} = require('../../lib/cluster/cjs/index.js'); +const { createHarness, parseFrame, successReplyFor } = require('./_fixtures.js'); + +test('unary calls round-trip params/result over the shared transport', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + + const promise = client.plan({ graph: { profile: 'openengine.graph.single-worker/v1' } }); + assert.equal(sink.frames.length, 1); + const sent = parseFrame(sink.frames[0]); + assert.equal(sent.method, 'plan'); + assert.deepEqual(sent.params, { graph: { profile: 'openengine.graph.single-worker/v1' } }); + + transport.routeIncoming(successReplyFor(sink.frames[0], { ok: true, diagnostics: [] })); + const result = await promise; + assert.deepEqual(result, { ok: true, diagnostics: [] }); +}); + +test('initialize() validates the echoed protocol version', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + + const promise = client.initialize(); + transport.routeIncoming( + successReplyFor(sink.frames[0], { + protocolVersion: PROTOCOL_VERSION, + capabilities: { logs: false, agentAttach: false }, + status: { phase: 'empty', observedGeneration: null, currentRunId: null, atCursor: null }, + }) + ); + const result = await promise; + assert.equal(result.protocolVersion, PROTOCOL_VERSION); +}); + +test('initialize() rejects a mismatched protocol version', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + + const promise = client.initialize(); + transport.routeIncoming( + successReplyFor(sink.frames[0], { + protocolVersion: 'openengine.cluster/v999', + capabilities: {}, + status: { phase: 'empty', observedGeneration: null, currentRunId: null, atCursor: null }, + }) + ); + await assert.rejects(() => promise, InvalidResponseError); +}); + +test('a well-formed JSON-RPC error response surfaces as RpcError with code/data', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + + const promise = client.get({}); + const request = parseFrame(sink.frames[0]); + transport.routeIncoming( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code: -32000, message: 'no active run', data: { code: 'NO_ACTIVE_RUN' } }, + }) + ); + await assert.rejects( + () => promise, + (error) => { + assert.ok(error instanceof RpcError); + assert.equal(error.code, -32000); + assert.equal(error.message, 'no active run'); + assert.deepEqual(error.data, { code: 'NO_ACTIVE_RUN', details: undefined }); + return true; + } + ); +}); + +// AC6: AbortSignal-based cancellation sends $/cancelRequest exactly once even when the signal +// fires twice (defensively) or cancellation is otherwise triggered more than once. +test('abort sends $/cancelRequest exactly once even if the signal fires twice', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + const controller = new AbortController(); + + const promise = client.get({}, { signal: controller.signal }); + controller.abort(); + + await assert.rejects(() => promise, AbortError); + + // A signal only fires once per AbortController, so trigger the internal cancel path a second + // time directly the way a caller combining manual cancellation with the signal could. + const cancelFrames = () => + sink.frames.filter((frame) => parseFrame(frame).method === '$/cancelRequest'); + await Promise.resolve(); + assert.equal(cancelFrames().length, 1); + + controller.abort(); // no-op: AbortController.abort() only fires listeners once regardless + await Promise.resolve(); + assert.equal(cancelFrames().length, 1); +}); + +test('an already-aborted signal rejects before sending any request frame', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects(() => client.get({}, { signal: controller.signal }), AbortError); + assert.equal(sink.frames.length, 0); +}); + +test('the eventual response after abort is swallowed, not surfaced as an unhandled rejection', async () => { + const { sink, transport } = createHarness(); + const client = new ClusterClient(transport); + const controller = new AbortController(); + + const promise = client.get({}, { signal: controller.signal }); + controller.abort(); + await assert.rejects(() => promise, AbortError); + + // The transport eventually "responds" to the now-abandoned request; this must not throw or + // produce an unhandled rejection anywhere in the process. + transport.routeIncoming( + successReplyFor(sink.frames[0], { spec: null, status: {}, atCursor: null }) + ); + await new Promise((resolve) => setImmediate(resolve)); +}); diff --git a/tests/cluster-client/durable-watch.test.js b/tests/cluster-client/durable-watch.test.js new file mode 100644 index 00000000..e896aed9 --- /dev/null +++ b/tests/cluster-client/durable-watch.test.js @@ -0,0 +1,165 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { DurableWatchClient } = require('../../lib/cluster/cjs/index.js'); +const { createWebSocketFactory } = require('./_fake-websocket.js'); + +function watchEventFrame(subscriptionId, runId, cursor) { + return { + jsonrpc: '2.0', + method: 'event', + params: { + subscriptionId, + runId, + cursor, + event: { type: 'node_begin', node: { node: 'worker', attempt: 1 }, input: { kind: 'null' } }, + }, + }; +} + +/** + * Scripts a fake cluster server: `get` always replies with a coherent snapshot; `watch` + * establishes a fresh subscription id and, only on a *reconnect* (`fromCursor` present), replays + * the cursor it was reconnected from once (legal at-least-once redelivery the client must dedup) + * before delivering a genuinely new one. + */ +function scriptedServer() { + let watchCounter = 0; + return (request, socket) => { + if (request.method === 'get') { + return { + reply: { + jsonrpc: '2.0', + id: request.id, + result: { + spec: null, + status: { + phase: 'running', + observedGeneration: 1, + currentRunId: 'run-1', + atCursor: 'c1', + }, + atCursor: 'c1', + }, + }, + }; + } + if (request.method === 'watch') { + watchCounter += 1; + const subscriptionId = `sub-${watchCounter}`; + socket.subscriptionId = subscriptionId; + const fromCursor = request.params.fromCursor ?? null; + const reply = { + jsonrpc: '2.0', + id: request.id, + result: { subscriptionId, runId: 'run-1', atCursor: fromCursor }, + }; + const after = + fromCursor === null + ? undefined + : (s) => { + s.push(watchEventFrame(subscriptionId, 'run-1', fromCursor)); + s.push(watchEventFrame(subscriptionId, 'run-1', 'c2')); + }; + return { reply, after }; + } + return null; // $/cancelRequest / subscription/cancel notifications need no reply + }; +} + +test('reconnect issues get()+watch(fromCursor) exclusively on the freshly dialed transport', async () => { + const { factory, sockets } = createWebSocketFactory(scriptedServer()); + + const durable = await DurableWatchClient.connect( + 'ws://fake-cluster', + { runId: 'run-1' }, + { + webSocketFactory: factory, + } + ); + assert.equal(sockets.length, 1); + const [firstSocket] = sockets; + + firstSocket.push(watchEventFrame(firstSocket.subscriptionId, 'run-1', 'c1')); + const firstEvent = await durable.next(); + assert.equal(firstEvent.type, 'event'); + assert.equal(firstEvent.cursor, 'c1'); + + firstSocket.simulateDisconnect(); + + const secondEvent = await durable.next(); + assert.equal(secondEvent.type, 'event'); + assert.equal(secondEvent.cursor, 'c2', 'the redelivered c1 must be deduped, only c2 is new'); + + assert.equal(sockets.length, 2, 'reconnect must dial exactly one fresh WebSocket'); + const [oldSocket, newSocket] = sockets; + + const oldMethods = oldSocket.sent.map((frame) => JSON.parse(frame).method); + assert.deepEqual( + oldMethods, + ['watch'], + 'the closed transport must never see get/watch again after reconnect' + ); + + const newMethods = newSocket.sent.map((frame) => JSON.parse(frame).method); + assert.deepEqual( + newMethods, + ['get', 'watch'], + 'the fresh transport must see a coherent get() then watch()' + ); + + const newWatchRequest = JSON.parse(newSocket.sent[1]); + assert.equal(newWatchRequest.params.runId, 'run-1'); + assert.equal(newWatchRequest.params.fromCursor, 'c1'); + + await durable.close(); +}); + +test('DurableWatchClient is directly usable with for-await and closes exactly once on break', async () => { + const { factory, sockets } = createWebSocketFactory(scriptedServer()); + const durable = await DurableWatchClient.connect( + 'ws://fake-cluster', + { runId: 'run-1' }, + { + webSocketFactory: factory, + } + ); + const [socket] = sockets; + socket.push(watchEventFrame(socket.subscriptionId, 'run-1', 'c1')); + + // eslint-disable-next-line no-unreachable-loop -- intentional single-pass break: exercises AC6's async-iterator return() cleanup + for await (const outcome of durable) { + assert.equal(outcome.type, 'event'); + break; + } + + await durable.close(); // idempotent: must not send a second subscription/cancel or re-close + const cancelFrames = socket.sent.filter( + (frame) => JSON.parse(frame).method === 'subscription/cancel' + ); + assert.equal(cancelFrames.length, 1); +}); + +test('close() is idempotent and does not attempt to reconnect a locally-closed client', async () => { + const { factory, sockets } = createWebSocketFactory(scriptedServer()); + const durable = await DurableWatchClient.connect( + 'ws://fake-cluster', + { runId: 'run-1' }, + { + webSocketFactory: factory, + } + ); + + await durable.close(); + await durable.close(); + + assert.equal(sockets.length, 1, 'a locally-initiated close must never trigger a reconnect'); + const cancelFrames = sockets[0].sent.filter( + (frame) => JSON.parse(frame).method === 'subscription/cancel' + ); + assert.equal(cancelFrames.length, 1); + + assert.equal(await durable.next(), null); +}); diff --git a/tests/cluster-client/goldens.test.js b/tests/cluster-client/goldens.test.js new file mode 100644 index 00000000..153fb19b --- /dev/null +++ b/tests/cluster-client/goldens.test.js @@ -0,0 +1,145 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + WatchSubscriptionClient, + LogsSubscriptionClient, + AgentAttachSubscriptionClient, + parseUnaryResponseLine, +} = require('../../lib/cluster/cjs/index.js'); +const { createHarness, successReplyFor } = require('./_fixtures.js'); + +const GOLDENS_DIR = path.join( + __dirname, + '..', + '..', + 'protocol', + 'openengine-cluster', + 'v1', + 'goldens' +); + +function loadNdjsonPairs(filename) { + const raw = fs.readFileSync(path.join(GOLDENS_DIR, filename), 'utf8').trim(); + const lines = raw.split('\n'); + assert.equal(lines.length % 2, 0, `${filename} must contain request/response line pairs`); + const pairs = []; + for (let i = 0; i < lines.length; i += 2) { + pairs.push({ requestLine: lines[i], responseLine: lines[i + 1] }); + } + return pairs; +} + +// Cross-language wire compatibility: every request/response pair recorded by the Rust +// implementation's goldens must parse cleanly (or fail identically) through this client's +// generic unary response parser -- driven off the golden files on disk, not a hardcoded list. +test('every ndjson golden response line parses through parseUnaryResponseLine as the Rust client would', () => { + const files = fs.readdirSync(GOLDENS_DIR).filter((entry) => entry.endsWith('.ndjson')); + assert.ok(files.length >= 10, 'expected the full set of protocol goldens to be present on disk'); + + for (const file of files) { + for (const { requestLine, responseLine } of loadNdjsonPairs(file)) { + const request = JSON.parse(requestLine); + const response = JSON.parse(responseLine); + if ('error' in response) { + assert.throws( + () => parseUnaryResponseLine(responseLine, request.id), + undefined, + `${file}: expected error response to throw` + ); + } else { + const result = parseUnaryResponseLine(responseLine, request.id); + assert.deepEqual( + result, + response.result, + `${file}: parsed result must match the golden result` + ); + } + } + } +}); + +function loadSessionGolden(filename) { + return JSON.parse(fs.readFileSync(path.join(GOLDENS_DIR, filename), 'utf8')); +} + +test('watch-session.json events replay through WatchSubscriptionEventStream unchanged', async () => { + const golden = loadSessionGolden('watch-session.json'); + assert.ok(golden.length >= 3); + + const harness = createHarness(); + const client = new WatchSubscriptionClient(harness.transport); + const promise = client.watch({}); + const requestFrame = harness.sink.frames.at(-1); + harness.transport.routeIncoming( + successReplyFor(requestFrame, { + subscriptionId: golden[0].subscriptionId, + runId: golden[0].runId, + }) + ); + const { stream } = await promise; + + for (const params of golden) { + harness.transport.routeIncoming(JSON.stringify({ jsonrpc: '2.0', method: 'event', params })); + } + + for (const params of golden) { + const outcome = await stream.next(); + assert.equal(outcome.type, 'event'); + assert.equal(outcome.runId, params.runId); + assert.equal(outcome.cursor, params.cursor); + assert.deepEqual(outcome.event, params.event); + } +}); + +test('logs-session.json records replay through CursorlessEventStream unchanged', async () => { + const golden = loadSessionGolden('logs-session.json'); + assert.ok(golden.length >= 1); + + const harness = createHarness(); + const client = new LogsSubscriptionClient(harness.transport); + const promise = client.logs({}, { logs: true }); + const requestFrame = harness.sink.frames.at(-1); + harness.transport.routeIncoming( + successReplyFor(requestFrame, { subscriptionId: golden[0].subscriptionId }) + ); + const { stream } = await promise; + + for (const params of golden) { + harness.transport.routeIncoming(JSON.stringify({ jsonrpc: '2.0', method: 'event', params })); + } + + for (const params of golden) { + const outcome = await stream.next(); + assert.equal(outcome.type, 'event'); + assert.deepEqual(outcome.event, params.record); + } +}); + +test('agent-attach-session.json events replay through CursorlessEventStream unchanged', async () => { + const golden = loadSessionGolden('agent-attach-session.json'); + assert.ok(golden.length >= 1); + + const harness = createHarness(); + const client = new AgentAttachSubscriptionClient(harness.transport); + const promise = client.agentAttach({ execution: 'exec-1' }, { agentAttach: true }); + const requestFrame = harness.sink.frames.at(-1); + harness.transport.routeIncoming( + successReplyFor(requestFrame, { subscriptionId: golden[0].subscriptionId }) + ); + const { stream } = await promise; + + for (const params of golden) { + harness.transport.routeIncoming(JSON.stringify({ jsonrpc: '2.0', method: 'event', params })); + } + + for (const params of golden) { + const outcome = await stream.next(); + assert.equal(outcome.type, 'event'); + assert.deepEqual(outcome.event, params.event); + } +}); diff --git a/tests/cluster-client/logs.test.js b/tests/cluster-client/logs.test.js new file mode 100644 index 00000000..c6bdace1 --- /dev/null +++ b/tests/cluster-client/logs.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { LogsSubscriptionClient } = require('../../lib/cluster/cjs/index.js'); +const { createHarness, successReplyFor, eventNotification } = require('./_fixtures.js'); +const { + assertCapabilityGateThrows, + assertCapabilityGatedFirstEvent, + describeSubscriptionContract, +} = require('./_subscription-contract.js'); + +const createClient = (transport) => new LogsSubscriptionClient(transport); +const invoke = (client, enabled) => client.logs({}, { logs: enabled }); + +test('logs() throws before opening any connection when capabilities.logs is false', () => { + assertCapabilityGateThrows({ createHarness, createClient, invoke }); +}); + +test('logs() delivers a first event for a capability-advertising server, cursorless', async () => { + await assertCapabilityGatedFirstEvent({ + createHarness, + createClient, + invoke, + wireMethod: 'logs', + firstEventParams: { record: { level: 'info', target: 'worker', message: 'hello' } }, + assertFirstEvent: (event) => + assert.deepEqual(event, { level: 'info', target: 'worker', message: 'hello' }), + }); +}); + +describeSubscriptionContract('logs', { + createHarness, + async establish(harness) { + const client = new LogsSubscriptionClient(harness.transport); + const promise = client.logs({}, { logs: true }); + const requestFrame = harness.sink.frames.at(-1); + harness.transport.routeIncoming(successReplyFor(requestFrame, { subscriptionId: 'sub-1' })); + const { stream } = await promise; + return { stream, subscriptionId: 'sub-1' }; + }, + pushEvent(harness, subscriptionId, n) { + harness.transport.routeIncoming( + eventNotification(subscriptionId, { + record: { level: 'info', target: 'worker', message: `m${n}` }, + }) + ); + }, +}); diff --git a/tests/cluster-client/parity.test.js b/tests/cluster-client/parity.test.js new file mode 100644 index 00000000..0c5b97b2 --- /dev/null +++ b/tests/cluster-client/parity.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const path = require('node:path'); + +const { + ClusterClient, + WatchSubscriptionClient, + LogsSubscriptionClient, + AgentAttachSubscriptionClient, +} = require('../../lib/cluster/cjs/index.js'); + +const openrpc = require( + path.join('..', '..', 'protocol', 'openengine-cluster', 'v1', 'openrpc.json') +); + +// AC1: every method in openrpc.json's methods array (the wire authority) must have a +// corresponding TypeScript client method, driven off the file's methods list rather than a +// hardcoded copy of it here. +const UNARY_METHODS = new Set([ + 'initialize', + 'plan', + 'apply', + 'update', + 'stop', + 'retry', + 'resubmit', + 'delete', + 'get', +]); +const SUBSCRIPTION_CLIENTS = { + watch: [WatchSubscriptionClient, 'watch'], + logs: [LogsSubscriptionClient, 'logs'], + 'agent/attach': [AgentAttachSubscriptionClient, 'agentAttach'], +}; + +test('openrpc.json declares the methods this parity test expects (fixture sanity)', () => { + assert.ok(Array.isArray(openrpc.methods) && openrpc.methods.length > 0); +}); + +test('every openrpc method has a corresponding TypeScript ClusterClient/subscription-client method', () => { + const missing = []; + for (const method of openrpc.methods) { + const name = method.name; + if (UNARY_METHODS.has(name)) { + if (typeof ClusterClient.prototype[name] !== 'function') missing.push(name); + continue; + } + const mapping = SUBSCRIPTION_CLIENTS[name]; + if (!mapping) { + missing.push(`${name} (no client mapping known to this test)`); + continue; + } + const [ClientClass, methodName] = mapping; + if (typeof ClientClass.prototype[methodName] !== 'function') missing.push(name); + } + assert.deepEqual( + missing, + [], + `openrpc methods missing a TypeScript client method: ${missing.join(', ')}` + ); +}); + +test('every declared TypeScript client method matches a real openrpc method name', () => { + const declaredNames = new Set(openrpc.methods.map((method) => method.name)); + for (const name of UNARY_METHODS) { + assert.ok(declaredNames.has(name), `${name} is not an openrpc method`); + } + for (const name of Object.keys(SUBSCRIPTION_CLIENTS)) { + assert.ok(declaredNames.has(name), `${name} is not an openrpc method`); + } +}); diff --git a/tests/cluster-client/transport.test.js b/tests/cluster-client/transport.test.js new file mode 100644 index 00000000..ce994cf4 --- /dev/null +++ b/tests/cluster-client/transport.test.js @@ -0,0 +1,131 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { requestIdKey } = require('../../lib/cluster/cjs/index.js'); +const { createHarness, parseFrame, successReplyFor } = require('./_fixtures.js'); + +// AC2: two callers sharing one MultiplexedTransport must never allocate colliding request ids, +// even when their sendRequest calls are issued concurrently and resolved out of order. Run +// deterministically across 3 repeated rounds per the plan's verification note. +for (let round = 1; round <= 3; round += 1) { + test(`shared transport never collides request ids under concurrent load (round ${round})`, async () => { + const { sink, transport } = createHarness(); + const callerACount = 25; + const callerBCount = 25; + + function fireCalls(count) { + const promises = []; + for (let i = 0; i < count; i += 1) { + const id = transport.nextRequestId(); + const frame = JSON.stringify({ jsonrpc: '2.0', id, method: 'get', params: {} }); + promises.push(transport.sendRequest(frame, id)); + } + return promises; + } + + const pendingA = fireCalls(callerACount); + const pendingB = fireCalls(callerBCount); + + const sentIds = sink.frames.map((frame) => parseFrame(frame).id); + const uniqueKeys = new Set(sentIds.map((id) => requestIdKey(id))); + assert.equal(uniqueKeys.size, sentIds.length, 'every allocated request id must be unique'); + assert.equal(sentIds.length, callerACount + callerBCount); + + // Resolve out of order (reverse) to prove routing is id-keyed, not order-dependent. + for (const frame of [...sink.frames].reverse()) { + transport.routeIncoming(successReplyFor(frame, { ok: true })); + } + + const results = await Promise.all([...pendingA, ...pendingB]); + assert.equal(results.length, callerACount + callerBCount); + for (const response of results) { + assert.equal(parseFrame(response.line).result.ok, true); + } + assert.equal(transport.pendingSize, 0); + }); +} + +// AC4: a failed writeFrame() on a closed transport must remove/reject exactly its own pending +// entry and leave the pending map empty, without disturbing unrelated in-flight calls. +test('a failed sendFrame removes only its own pending entry and leaves unrelated calls intact', async () => { + const { sink, transport } = createHarness(); + + const goodId = transport.nextRequestId(); + const goodFrame = JSON.stringify({ jsonrpc: '2.0', id: goodId, method: 'get', params: {} }); + const goodPromise = transport.sendRequest(goodFrame, goodId); + assert.equal(transport.pendingSize, 1); + + sink.failNextSends(1); + const failingId = transport.nextRequestId(); + const failingFrame = JSON.stringify({ jsonrpc: '2.0', id: failingId, method: 'get', params: {} }); + + await assert.rejects( + () => transport.sendRequest(failingFrame, failingId), + /WebSocket is not open/ + ); + assert.equal(transport.pendingSize, 1, 'the failed send must not leave a pending entry behind'); + + transport.routeIncoming(successReplyFor(goodFrame, { ok: true })); + const goodResponse = await goodPromise; + assert.equal(parseFrame(goodResponse.line).result.ok, true); + assert.equal( + transport.pendingSize, + 0, + 'pending map must be empty once the only real call settles' + ); +}); + +test('repeated failed sends on a closed transport leave pending state empty every time', async () => { + const { sink, transport } = createHarness(); + sink.failNextSends(5); + + for (let i = 0; i < 5; i += 1) { + const id = transport.nextRequestId(); + const frame = JSON.stringify({ jsonrpc: '2.0', id, method: 'get', params: {} }); + await assert.rejects(() => transport.sendRequest(frame, id)); + assert.equal(transport.pendingSize, 0); + } +}); + +test('rejects a duplicate request id registered while the first is still pending', async () => { + const { transport } = createHarness(); + const id = transport.nextRequestId(); + const frame = JSON.stringify({ jsonrpc: '2.0', id, method: 'get', params: {} }); + const first = transport.sendRequest(frame, id); + await assert.rejects(() => transport.sendRequest(frame, id), /already pending/); + transport.finish(); + await assert.rejects(() => first); +}); + +test('nextWatchRequestId mints distinct watch- ids shared across callers', () => { + const { transport } = createHarness(); + const ids = new Set(); + for (let i = 0; i < 10; i += 1) { + const id = transport.nextWatchRequestId(); + assert.match(String(id), /^watch-\d+$/); + ids.add(id); + } + assert.equal(ids.size, 10); +}); + +test('finish() rejects every pending call and ends every open subscription queue', async () => { + const { transport } = createHarness(); + const id = transport.nextRequestId(); + const frame = JSON.stringify({ jsonrpc: '2.0', id, method: 'watch', params: {} }); + const pending = transport.sendRequest(frame, id); + transport.routeIncoming(successReplyFor(frame, { subscriptionId: 'sub-1' })); + const response = await pending; + assert.ok(response.queue); + + const secondId = transport.nextRequestId(); + const secondFrame = JSON.stringify({ jsonrpc: '2.0', id: secondId, method: 'get', params: {} }); + const secondPending = transport.sendRequest(secondFrame, secondId); + + transport.finish(); + + await assert.rejects(() => secondPending, /connection closed/); + assert.equal(await response.queue.recv(), null); + assert.equal(transport.pendingSize, 0); +}); diff --git a/tests/cluster-client/watch.test.js b/tests/cluster-client/watch.test.js new file mode 100644 index 00000000..635eeb40 --- /dev/null +++ b/tests/cluster-client/watch.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { WatchSubscriptionClient } = require('../../lib/cluster/cjs/index.js'); +const { createHarness, parseFrame, successReplyFor, eventNotification } = require('./_fixtures.js'); +const { describeSubscriptionContract } = require('./_subscription-contract.js'); + +function sampleEvent() { + return { type: 'node_begin', node: { node: 'worker', attempt: 1 }, input: { kind: 'null' } }; +} + +function establish(harness, params = {}) { + const { transport } = harness; + const client = new WatchSubscriptionClient(transport); + const promise = client.watch(params); + const requestFrame = harness.sink.frames.at(-1); + transport.routeIncoming( + successReplyFor(requestFrame, { subscriptionId: 'sub-1', runId: 'run-1' }) + ); + return promise; +} + +test('delivers events in order and exposes lastDeliveredCursor/currentRunId', async () => { + const harness = createHarness(); + const { stream } = await establish(harness); + + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + const first = await stream.next(); + assert.equal(first.type, 'event'); + assert.equal(first.cursor, 'c1'); + assert.equal(stream.lastDeliveredCursor(), 'c1'); + assert.equal(stream.currentRunId(), 'run-1'); +}); + +test('dedups a legally redelivered (runId, cursor) pair', async () => { + const harness = createHarness(); + const { stream } = await establish(harness); + + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c2', event: sampleEvent() }) + ); + + const first = await stream.next(); + assert.equal(first.cursor, 'c1'); + const second = await stream.next(); + assert.equal(second.cursor, 'c2', 'the duplicate c1 delivery must be silently dropped'); +}); + +test('subscription-level reconnect carries the dedup set across the same live transport', async () => { + const harness = createHarness(); + const { stream } = await establish(harness); + + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + const first = await stream.next(); + assert.equal(first.cursor, 'c1'); + + const reconnectPromise = stream.reconnect(); + const reconnectFrame = harness.sink.frames.at(-1); + const reconnectRequest = parseFrame(reconnectFrame); + assert.equal(reconnectRequest.method, 'watch'); + assert.equal(reconnectRequest.params.fromCursor, 'c1'); + assert.equal(reconnectRequest.params.runId, 'run-1'); + + harness.transport.routeIncoming( + successReplyFor(reconnectFrame, { subscriptionId: 'sub-2', runId: 'run-1' }) + ); + const { stream: reconnected } = await reconnectPromise; + + // Legal at-least-once redelivery of c1 on the new subscription, then a genuinely new c2. + harness.transport.routeIncoming( + eventNotification('sub-2', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + harness.transport.routeIncoming( + eventNotification('sub-2', { runId: 'run-1', cursor: 'c2', event: sampleEvent() }) + ); + const next = await reconnected.next(); + assert.equal(next.cursor, 'c2', 'dedup set must survive the reconnect boundary'); +}); + +// AC5 (overflow -> exactly one SLOW_CONSUMER close) and AC6 (iterator return() -> exactly one +// cancel) are the same contract watch/logs/agent-attach all share; see _subscription-contract.js. +describeSubscriptionContract('watch', { + createHarness, + async establish(harness) { + const { stream } = await establish(harness); + return { stream, subscriptionId: 'sub-1' }; + }, + pushEvent(harness, subscriptionId, n) { + harness.transport.routeIncoming( + eventNotification(subscriptionId, { runId: 'run-1', cursor: `c${n}`, event: sampleEvent() }) + ); + }, +}); + +test('a terminal "done" close ends a for-await loop without hanging', async () => { + const harness = createHarness(); + const { stream } = await establish(harness); + + harness.transport.routeIncoming( + eventNotification('sub-1', { runId: 'run-1', cursor: 'c1', event: sampleEvent() }) + ); + + const seen = []; + const notifyClosed = () => + harness.transport.routeIncoming( + JSON.stringify({ + jsonrpc: '2.0', + method: 'subscription/closed', + params: { subscriptionId: 'sub-1', reason: 'done', lastDeliveredCursor: 'c1' }, + }) + ); + + let iterations = 0; + for await (const outcome of stream) { + seen.push(outcome); + iterations += 1; + if (iterations === 1) notifyClosed(); + if (iterations > 5) throw new Error('for-await loop did not terminate after a done close'); + } + + assert.equal(seen.length, 2); + assert.equal(seen[0].type, 'event'); + assert.equal(seen[1].type, 'closed'); + assert.equal(seen[1].reason, 'done'); +}); diff --git a/tsconfig.cluster.cjs.build.json b/tsconfig.cluster.cjs.build.json new file mode 100644 index 00000000..eaef66ab --- /dev/null +++ b/tsconfig.cluster.cjs.build.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.cluster.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "src/cluster", + "outDir": "lib/cluster/cjs", + "noEmit": false, + "declaration": true, + "declarationDir": "lib/cluster/types", + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/cluster/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage", "lib", "tui-rs/target"] +} diff --git a/tsconfig.cluster.esm.build.json b/tsconfig.cluster.esm.build.json new file mode 100644 index 00000000..d5f03914 --- /dev/null +++ b/tsconfig.cluster.esm.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.cluster.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src/cluster", + "outDir": "lib/cluster/esm", + "noEmit": false, + "declaration": false, + "sourceMap": true + }, + "include": ["src/cluster/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage", "lib", "tui-rs/target"] +} diff --git a/tsconfig.cluster.json b/tsconfig.cluster.json new file mode 100644 index 00000000..c5e93f12 --- /dev/null +++ b/tsconfig.cluster.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["node"], + "esModuleInterop": true, + "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", "dist", "coverage", "lib", "tui-rs/target"] +}