Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ report/
test-metadata-manual.sh
test-isolated-fix.js
lib/agent-cli-provider/
lib/cluster/
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ coverage
.tsbuildinfo
.eslintcache
lib/agent-cli-provider
lib/cluster
src/cluster/generated
protocol/openengine-cluster/v1
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero
| Connection core/admission | `crates/openengine-cluster-server/src/connection.rs`, `connection/` |
| NDJSON response pump | `crates/openengine-cluster-client/src/ndjson_pump.rs` |
| Cluster typed transports | `crates/openengine-cluster-client/` |
| TypeScript cluster client | `src/cluster/` |
| TypeScript protocol emitter | `scripts/generate-cluster-types.js` |
| Cluster fixtures/artifacts | `crates/openengine-cluster-testkit/` |
| Portable backend conformance | `crates/openengine-cluster-testkit/src/conformance.rs` |
| Scripted admission fixtures | `crates/openengine-cluster-testkit/src/admission.rs` |
Expand All @@ -137,6 +139,11 @@ are excluded from Prettier; never format them independently.
Native release metadata and npm installer code stay outside the Rust-only `zeroshot-rust/`
package. `distribution/zeroshot-rust-targets.json` is the authoritative release target list;
the workflow matrix and checksum coverage must match it exactly.
The published Node binding is the standalone `@the-open-engine/zeroshot/cluster` subpath.
`Connection` exclusively owns request IDs and transport teardown; watch reconnect consumes an old
stream exactly once on an application-supplied fresh connection. Keep `src/cluster/` isolated from
product internals, and regenerate its wire types from the authoritative protocol artifacts with
`npm run protocol:generate`.
The protocol and server crates own wire contracts, backend traits, the dispatcher, and transports.
Portable external conformance is the immutable public catalog in the testkit and covers only
backend-neutral behavior observable through public dispatcher and typed subscription surfaces.
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export default [
'hooks/**',
'lib/tui-backend/**',
'lib/agent-cli-provider/**',
'lib/cluster/**',
],
},
prettierConfig,
Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,26 @@
"test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
"postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
"start": "node cli/index.js",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && npm run typecheck:cluster",
"typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
"typecheck:cluster": "tsc --project tsconfig.cluster.json",
"lint:agent-cli-provider": "eslint \"src/agent-cli-provider/**/*.ts\" \"tests/agent-cli-provider/**/*.ts\"",
"build:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.build.json",
"build:cluster": "npm run protocol:generate && tsc --project tsconfig.cluster.cjs.json && tsc --project tsconfig.cluster.esm.json && node scripts/build-cluster.js",
"test:agent-cli-provider": "node --test tests/agent-cli-provider/*.test.js",
"test:providers:live": "npm run build:agent-cli-provider && node scripts/live-provider-smoke.js",
"check:agent-cli-provider": "npm run check:agent-cli-provider:ci",
"check:agent-cli-provider:ci": "npm run typecheck:agent-cli-provider && npm run lint:agent-cli-provider && npm run build:agent-cli-provider && npm run test:agent-cli-provider",
"test:cluster-client": "npm run build:cluster && node --test tests/cluster/client.test.js tests/cluster/parity.test.js tests/cluster/architecture.test.js tests/cluster/verifier-regressions.test.js",
"test:cluster-package": "npm run build:cluster && node --test tests/cluster/package.test.js",
"dev:link": "npm link",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"validate:templates": "node scripts/validate-templates.js",
"format": "prettier --write .",
"format:check": "prettier --check .",
"protocol:check": "cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --check",
"protocol:generate": "node scripts/generate-cluster-types.js",
"protocol:check": "cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --check && node scripts/generate-cluster-types.js --check",
"rust:distribution:check": "node scripts/rust-distribution.js check-repository",
"rust:check": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace",
"deadcode": "ts-prune --skip node_modules",
Expand All @@ -68,6 +73,7 @@
"release:preflight": "node scripts/release-preflight.js",
"release:recover": "node scripts/release-recovery.js",
"release:assert-published": "node scripts/assert-release-published.js",
"prepack": "npm run build:cluster",
"prepublishOnly": "npm run lint && npm run typecheck && npm run check:agent-cli-provider:ci",
"prepare": "npm run build:agent-cli-provider && husky"
},
Expand Down Expand Up @@ -115,6 +121,17 @@
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"exports": {
".": "./src/orchestrator.js",
"./cluster": {
"types": "./lib/cluster/index.d.ts",
"import": "./lib/cluster/index.mjs",
"require": "./lib/cluster/index.cjs",
"default": "./lib/cluster/index.cjs"
},
"./package.json": "./package.json",
"./*": "./*"
},
"files": [
"src/",
"lib/",
Expand Down Expand Up @@ -142,6 +159,9 @@
"pidusage": "^4.0.1",
"proper-lockfile": "^4.1.2"
},
"optionalDependencies": {
"ws": "^8.18.3"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
Expand Down
43 changes: 43 additions & 0 deletions scripts/build-cluster.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const root = path.resolve(__dirname, '..');
const buildRoot = path.join(root, '.cluster-build');
const outputRoot = path.join(root, 'lib/cluster');

function filesBelow(directory) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries.flatMap((entry) => {
const absolute = path.join(directory, entry.name);
return entry.isDirectory() ? filesBelow(absolute) : [absolute];
});
}

function copyBuild(sourceRoot, mode) {
for (const source of filesBelow(sourceRoot)) {
const relative = path.relative(sourceRoot, source);
const extension = path.extname(relative);
if (extension === '.js') {
const targetExtension = mode === 'cjs' ? '.cjs' : '.mjs';
const target = path.join(outputRoot, relative.slice(0, -3) + targetExtension);
let content = fs.readFileSync(source, 'utf8');
content = content.replace(
/(["'])(\.\.?\/[^"']+)\.js\1/g,
(_match, quote, specifier) => `${quote}${specifier}${targetExtension}${quote}`
);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
} else if (mode === 'cjs' && extension === '.ts' && relative.endsWith('.d.ts')) {
const target = path.join(outputRoot, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
}
}
}

fs.rmSync(outputRoot, { recursive: true, force: true });
copyBuild(path.join(buildRoot, 'cjs'), 'cjs');
copyBuild(path.join(buildRoot, 'esm'), 'esm');
fs.rmSync(buildRoot, { recursive: true, force: true });
203 changes: 203 additions & 0 deletions scripts/generate-cluster-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const ROOT = path.resolve(__dirname, '..');
const OPENRPC_PATH = path.join(ROOT, 'protocol/openengine-cluster/v1/openrpc.json');
const SCHEMA_PATH = path.join(ROOT, 'protocol/openengine-cluster/v1/schema.json');
const PROTOCOL_RS = path.join(ROOT, 'crates/openengine-cluster-protocol/src/lib.rs');
const WATCH_RS = path.join(ROOT, 'crates/openengine-cluster-protocol/src/watch.rs');
const CLIENT_RS = path.join(ROOT, 'crates/openengine-cluster-client/src/lib.rs');
const OUTPUT_PATH = path.join(ROOT, 'src/cluster/generated/protocol.ts');
const SCHEMA_OUTPUT_PATH = path.join(ROOT, 'src/cluster/generated/protocol-schema.ts');

function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

function rustString(source, name) {
const match = source.match(new RegExp(`pub const ${name}: &str = "([^"]+)";`));
if (!match) throw new Error(`missing Rust string constant ${name}`);
return match[1];
}

function rustNumber(source, name) {
const match = source.match(new RegExp(`(?:pub )?const ${name}: [^=]+ = (-?[0-9_]+);`));
if (!match) throw new Error(`missing Rust numeric constant ${name}`);
return Number(match[1].replaceAll('_', ''));
}

function refName(ref) {
const prefix = '#/$defs/';
if (!ref.startsWith(prefix)) throw new Error(`unsupported non-local schema reference ${ref}`);
return ref.slice(prefix.length);
}

function literal(value) {
return JSON.stringify(value);
}

function renderCombination(schema) {
for (const [keyword, operator] of [
['oneOf', ' | '],
['anyOf', ' | '],
['allOf', ' & '],
]) {
const branches = schema[keyword];
if (!Array.isArray(branches)) continue;
const combination = branches.map(schemaType).map(parenthesize).join(operator);
const hasBase =
schema.type !== undefined ||
schema.properties !== undefined ||
schema.additionalProperties !== undefined;
if (!hasBase) return combination;
const base = { ...schema };
delete base[keyword];
return `(${schemaType(base)}) & (${combination})`;
}
return undefined;
}

function renderObject(schema) {
if (schema.properties) {
const required = new Set(schema.required ?? []);
const fields = Object.entries(schema.properties).map(
([name, value]) =>
`readonly ${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${schemaType(value)};`
);
if (schema.additionalProperties && schema.additionalProperties !== false) {
fields.push(`readonly [key: string]: ${schemaType(schema.additionalProperties)};`);
}
return `{ ${fields.join(' ')} }`;
}
if (Array.isArray(schema.required) && schema.required.length > 0) {
const fields = schema.required.map((name) => `readonly ${JSON.stringify(name)}: unknown;`);
return `{ ${fields.join(' ')} }`;
}
if (schema.additionalProperties && schema.additionalProperties !== false) {
return `{ readonly [key: string]: ${schemaType(schema.additionalProperties)} }`;
}
return schema.type === 'object' ? 'Record<string, never>' : 'unknown';
}

function schemaType(schema) {
if (schema === true) return 'unknown';
if (schema === false) return 'never';
if (!schema || typeof schema !== 'object') throw new Error('invalid JSON schema');
if ('$ref' in schema) return refName(schema.$ref);
if ('const' in schema) return literal(schema.const);
if (Array.isArray(schema.enum)) return schema.enum.map(literal).join(' | ') || 'never';
const combination = renderCombination(schema);
if (combination !== undefined) return combination;
if (Array.isArray(schema.type)) {
return schema.type.map((type) => schemaType({ ...schema, type })).join(' | ');
}
switch (schema.type) {
case 'null':
return 'null';
case 'boolean':
return 'boolean';
case 'integer':
case 'number':
return 'number';
case 'string':
return 'string';
case 'array':
return `ReadonlyArray<${schemaType(schema.items ?? true)}>`;
case 'object':
case undefined:
return renderObject(schema);
default:
throw new Error(`unsupported JSON schema type ${String(schema.type)}`);
}
}

function parenthesize(value) {
return value.includes(' | ') || value.includes(' & ') ? `(${value})` : value;
}

function generate() {
const openrpc = readJson(OPENRPC_PATH);
const schema = readJson(SCHEMA_PATH);
const protocolRust = fs.readFileSync(PROTOCOL_RS, 'utf8');
const watchRust = fs.readFileSync(WATCH_RS, 'utf8');
const clientRust = fs.readFileSync(CLIENT_RS, 'utf8');
const methods = openrpc.methods.map((method) => method.name);
const subscriptionMethods = new Set(['watch', 'logs', 'agent/attach']);
const unaryMethods = methods.filter((method) => !subscriptionMethods.has(method));
const resultDefinitions = Object.fromEntries(
openrpc.methods.map((method) => [method.name, method.result.schema.$ref.split('/').at(-1)])
);
const aliases = Object.entries(schema.$defs)
.map(([name, definition]) => `export type ${name} = ${schemaType(definition)};`)
.join('\n');

return (
`// Generated by scripts/generate-cluster-types.js from the checked-in Cluster Protocol v1 artifacts.\n// Do not edit this file by hand. Run npm run protocol:generate.\n\n` +
`export const JSON_RPC_VERSION = ${literal(rustString(protocolRust, 'JSON_RPC_VERSION'))} as const;\n` +
`export const PROTOCOL_VERSION = ${literal(rustString(protocolRust, 'PROTOCOL_VERSION'))} as const;\n` +
`export const SUBSCRIPTION_QUEUE_CAPACITY = ${rustNumber(watchRust, 'DEFAULT_SUBSCRIPTION_QUEUE_CAPACITY')} as const;\n` +
`export const MAX_FRAME_BYTES = ${rustNumber(clientRust, 'MAX_FRAME_BYTES')} as const;\n` +
`export const CLUSTER_METHODS = ${JSON.stringify(methods)} as const;\n` +
`export type ClusterMethod = (typeof CLUSTER_METHODS)[number];\n\n` +
`export const UNARY_METHODS = ${JSON.stringify(unaryMethods)} as const;\n` +
`export type UnaryClusterMethod = (typeof UNARY_METHODS)[number];\n` +
`export const SUBSCRIPTION_METHODS = ${JSON.stringify([...subscriptionMethods])} as const;\n` +
`export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];\n` +
`export const METHOD_RESULT_DEFINITIONS = ${JSON.stringify(resultDefinitions)} as const;\n\n` +
`export const JSON_RPC_ERROR_CODES = {\n` +
` PARSE_ERROR: ${rustNumber(protocolRust, 'PARSE_ERROR')},\n` +
` INVALID_REQUEST: ${rustNumber(protocolRust, 'INVALID_REQUEST')},\n` +
` METHOD_NOT_FOUND: ${rustNumber(protocolRust, 'METHOD_NOT_FOUND')},\n` +
` INVALID_PARAMS: ${rustNumber(protocolRust, 'INVALID_PARAMS')},\n` +
` INTERNAL_ERROR: ${rustNumber(protocolRust, 'INTERNAL_ERROR')},\n` +
` APPLICATION_ERROR: ${rustNumber(protocolRust, 'APPLICATION_ERROR')},\n` +
`} as const;\n\n` +
`export const DOMAIN_ERROR_CODES = ${JSON.stringify([
rustString(watchRust, 'NOT_FOUND'),
rustString(watchRust, 'GONE'),
rustString(watchRust, 'SLOW_CONSUMER'),
rustString(protocolRust, 'UNSUPPORTED_PROTOCOL_VERSION'),
rustString(protocolRust, 'INTERNAL_ERROR_CODE'),
])} as const;\n\n` +
aliases +
'\n\n' +
`export interface ClusterMethodParams {\n` +
` readonly initialize: InitializeParams; readonly plan: PlanParams; readonly apply: ApplyParams;\n` +
` readonly update: UpdateParams; readonly stop: StopParams; readonly retry: RetryParams;\n` +
` readonly resubmit: ResubmitParams; readonly delete: DeleteParams; readonly get: GetParams;\n` +
` readonly watch: WatchParams; readonly logs: LogsParams; readonly 'agent/attach': AgentAttachParams;\n` +
`}\n\n` +
`export interface ClusterMethodResults {\n` +
` readonly initialize: InitializeResult; readonly plan: PlanResult; readonly apply: ApplyResult;\n` +
` readonly update: UpdateResult; readonly stop: StopResult; readonly retry: RetryResult;\n` +
` readonly resubmit: ResubmitResult; readonly delete: DeleteResult; readonly get: GetResult;\n` +
` readonly watch: WatchResult; readonly logs: LogsResult; readonly 'agent/attach': AgentAttachResult;\n` +
`}\n`
);
}

const expected = generate();
const schemaExpected = `// Generated from protocol/openengine-cluster/v1/schema.json. Do not edit.\nexport const CLUSTER_PROTOCOL_SCHEMA: unknown = ${JSON.stringify(readJson(SCHEMA_PATH))};\n`;
const check = process.argv.includes('--check');
const outputs = [
[OUTPUT_PATH, expected],
[SCHEMA_OUTPUT_PATH, schemaExpected],
];
if (check) {
for (const [outputPath, outputExpected] of outputs) {
const actual = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
if (actual !== outputExpected) {
console.error(
`Generated cluster protocol output is out of date: ${path.relative(ROOT, outputPath)}`
);
process.exitCode = 1;
}
}
} else {
for (const [outputPath, outputExpected] of outputs) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, outputExpected);
}
}
Loading
Loading