Skip to content
Closed
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/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ coverage
.tsbuildinfo
.eslintcache
lib/agent-cli-provider
lib/cluster
protocol/openengine-cluster/v1
29 changes: 15 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,21 @@ IS THIS HOW A SENIOR STAFF ARCHITECT WOULD DO IT? ACT LIKE ONE.

## Where to Look

| Concept | File |
| ------------------------ | ----------------------------------- |
| Conductor classification | `src/conductor-bootstrap.js` |
| Base templates | `cluster-templates/base-templates/` |
| Message bus | `src/message-bus.js` |
| Ledger (SQLite) | `src/ledger.js` |
| Trigger evaluation | `src/logic-engine.js` |
| Agent wrapper | `src/agent-wrapper.js` |
| Docker mounts/env | `lib/docker-config.js` |
| Container lifecycle | `src/isolation-manager.js` |
| Issue providers | `src/issue-providers/` |
| Git remote detection | `lib/git-remote-utils.js` |
| Input helpers | `src/input-helpers.js` |
| Settings | `lib/settings.js` |
| Concept | File |
| ------------------------- | ----------------------------------- |
| Conductor classification | `src/conductor-bootstrap.js` |
| Base templates | `cluster-templates/base-templates/` |
| Message bus | `src/message-bus.js` |
| Ledger (SQLite) | `src/ledger.js` |
| Trigger evaluation | `src/logic-engine.js` |
| Agent wrapper | `src/agent-wrapper.js` |
| Docker mounts/env | `lib/docker-config.js` |
| Container lifecycle | `src/isolation-manager.js` |
| Issue providers | `src/issue-providers/` |
| Git remote detection | `lib/git-remote-utils.js` |
| Input helpers | `src/input-helpers.js` |
| Settings | `lib/settings.js` |
| TypeScript cluster client | `src/cluster/` |

## CLI Quick Reference

Expand Down
110 changes: 110 additions & 0 deletions docs/openengine-cluster-protocol/v1/typescript-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Cluster Protocol v1 TypeScript client

This document defines the TypeScript client's public surface: import paths, the injectable
WebSocket factory, and the two client-side guarantees that diverge from a naive per-call
implementation -- shared request-id allocation and stale-transport-free watch reconnect. The Rust
client and the wire types in `watch.md`/`data-plane.md` remain the sole protocol authority; this
package only binds them to TypeScript. Generated wire types live in `src/cluster/generated/` and
are produced solely by `scripts/generate-cluster-wire-types.js` from
`protocol/openengine-cluster/v1/*.json` -- never hand-edited.

## Import paths

The client is published as the `@the-open-engine/zeroshot/cluster` subpath, resolvable from
CommonJS, ESM, and TypeScript alike; the package's existing default entrypoint and deep imports are
unchanged.

```js
// CommonJS
const { connectCluster } = require('@the-open-engine/zeroshot/cluster');
```

```js
// ESM
import { connectCluster } from '@the-open-engine/zeroshot/cluster';
```

```ts
// TypeScript
import { connectCluster, type ClusterConnection } from '@the-open-engine/zeroshot/cluster';
```

## Connecting

```ts
import { connectCluster } from '@the-open-engine/zeroshot/cluster';

const connection = await connectCluster('wss://cluster.example.com/v1');
const status = await connection.client.get();
const watch = await connection.watch({ runId: status.status.currentRunId ?? undefined });

for await (const delivered of watch) {
console.log(delivered.runId, delivered.cursor, delivered.event);
}

connection.close();
```

`connectCluster` dials one WebSocket, wraps it in a single `ConnectionMultiplexer`, and returns a
`ClusterClient` plus factories for the three subscription methods (`watch`, `logs`, `attach`) --
all sharing that one transport and its id space.

## WebSocket factory injection

Node >=18 has no global `WebSocket`, so the default factory lazily `import()`s the `ws` package the
first time it actually needs to dial (never eagerly, so a browser bundle can tree-shake that branch
out). Node >=22 and browsers use the global `WebSocket` instead. Inject a factory to point at a
custom implementation (a test double, a proxy-aware socket, a browser-specific wrapper):

```ts
import { connectCluster, type WebSocketFactory } from '@the-open-engine/zeroshot/cluster';

const webSocketFactory: WebSocketFactory = (url, protocols) => new MyWebSocket(url, protocols);

const connection = await connectCluster('wss://cluster.example.com/v1', { webSocketFactory });
```

A factory returns anything satisfying `WebSocketLike` (readyState, `send`, `close`, and DOM-style
`addEventListener`/`removeEventListener` for `open`/`message`/`close`/`error`), synchronously or as
a `Promise`.

## Shared-transport request-id allocation

Every `ClusterClient` and subscription factory built on one `ConnectionMultiplexer` mints unary and
subscription-establish request ids from that multiplexer's single shared counter -- never from a
per-client counter. Two `ClusterClient`s constructed over the same transport (for example, one held
by application code and one held internally by a reconnect path) can issue concurrent calls without
ever colliding on request id, because id allocation is owned by the connection, not by however many
clients sit on top of it.

## Watch reconnect

`WatchSubscriptionStream.reconnect(freshClient)` re-establishes a `watch` subscription after its
underlying transport has failed or been closed. It always dials through `freshClient`'s own
transport -- never the original, presumed-dead one -- replays from the last event this stream
actually admitted (or, if it never admitted one, from the coherent tail cursor captured at this
stream's own establishment), and carries its `(runId, cursor)` de-duplication set across the
boundary so a redelivered boundary event is suppressed, not yielded twice:

```ts
import { connectCluster } from '@the-open-engine/zeroshot/cluster';

let connection = await connectCluster(url);
let watch = await connection.watch({ runId });

async function reconnect() {
connection = await connectCluster(url);
watch = await watch.reconnect(connection.client);
}
```

## Cancellation

Unary calls accept an `AbortSignal` via `{signal}`; firing it sends `$/cancelRequest` and rejects
the call's promise exactly once with an `AbortError`, even under concurrent double-abort.
Subscriptions expose `.cancel()`, and their async iterator's `return()` (invoked by `for await...of`
`break`/`return`/`throw`) routes through the same idempotent guard -- either path, or both at once,
sends `subscription/cancel` exactly once.

`logs` and `agent/attach` subscriptions never expose a cursor or a `reconnect` method: both are
non-resumable on the wire, matching their Rust contract exactly.
54 changes: 54 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,58 @@ export default [
],
},
},
{
files: ['src/cluster/**/*.ts', 'tests/cluster-client/**/*.ts', 'tests/cluster-package/**/*.ts'],
ignores: ['src/cluster/generated/**'],
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',
'@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: [
Expand Down Expand Up @@ -329,6 +381,8 @@ export default [
'hooks/**',
'lib/tui-backend/**',
'lib/agent-cli-provider/**',
'src/cluster/generated/**',
'lib/cluster/**',
],
},
prettierConfig,
Expand Down
92 changes: 91 additions & 1 deletion package-lock.json

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

Loading
Loading