@xemahq/worker-runtime-opencode 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -3
- package/src/index.ts +0 -28
- package/src/lib/factory.ts +0 -56
- package/src/lib/opencode-worker-runtime.ts +0 -344
- package/src/lib/session-bundle.ts +0 -232
- package/src/lib/worker-ref.ts +0 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/worker-runtime-opencode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Opencode WorkerRuntime driver — wraps the workspace-proxy control-session handshake and the opencode SSE event stream behind the kernel WorkerRuntime contract.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"main": "dist/index.js",
|
|
19
19
|
"types": "dist/index.d.ts",
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
22
|
-
"src"
|
|
21
|
+
"dist"
|
|
23
22
|
],
|
|
24
23
|
"devDependencies": {
|
|
25
24
|
"@types/jest": "^30.0.0",
|
package/src/index.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ── @xemahq/worker-runtime-opencode — barrel export ──
|
|
3
|
-
//
|
|
4
|
-
// opencode driver behind the
|
|
5
|
-
// `@xemahq/kernel-contracts/worker-runtime` interface. The orchestrator and
|
|
6
|
-
// session-launch services depend on THIS package's `WorkerRuntime` API;
|
|
7
|
-
// they never reach into `biomes/workspace-proxy/api/workspace-proxy/` or `apps/opencode-pool-api/`.
|
|
8
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
-
|
|
10
|
-
export {
|
|
11
|
-
OpencodeWorkerRuntime,
|
|
12
|
-
OPENCODE_CONNECTOR_KIND,
|
|
13
|
-
type OpencodePodSpec,
|
|
14
|
-
} from './lib/opencode-worker-runtime';
|
|
15
|
-
export { createOpencodeWorkerRuntime } from './lib/factory';
|
|
16
|
-
export {
|
|
17
|
-
buildOpencodeSessionBundle,
|
|
18
|
-
computeSessionBindingRevision,
|
|
19
|
-
computeStaticBundleFingerprint,
|
|
20
|
-
decodeOpencodeSessionBundle,
|
|
21
|
-
type OpencodeBundleSubAgent,
|
|
22
|
-
type OpencodeSessionBundlePayload,
|
|
23
|
-
} from './lib/session-bundle';
|
|
24
|
-
export type {
|
|
25
|
-
BundleCallerContext,
|
|
26
|
-
OpencodeRuntimeConfig,
|
|
27
|
-
WorkerRef,
|
|
28
|
-
} from './lib/worker-ref';
|
package/src/lib/factory.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ── OpencodeWorkerRuntime — factory ──
|
|
3
|
-
//
|
|
4
|
-
// Single entry-point the orchestrator calls per allocated worker. Keeping
|
|
5
|
-
// construction behind a factory makes future test-injection seams (mock
|
|
6
|
-
// fetch, mock SSE body) trivial without exposing the class constructor's
|
|
7
|
-
// shape to consumers.
|
|
8
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
-
|
|
10
|
-
import { OpencodeWorkerRuntime } from './opencode-worker-runtime';
|
|
11
|
-
import type { OpencodeRuntimeConfig } from './worker-ref';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Construct an `OpencodeWorkerRuntime` bound to a specific worker.
|
|
15
|
-
*
|
|
16
|
-
* The orchestrator calls this once `workload-runtime-api` returns a
|
|
17
|
-
* scheduled worker + the per-allocation service token has been minted.
|
|
18
|
-
*/
|
|
19
|
-
export function createOpencodeWorkerRuntime(
|
|
20
|
-
config: OpencodeRuntimeConfig,
|
|
21
|
-
): OpencodeWorkerRuntime {
|
|
22
|
-
validate(config);
|
|
23
|
-
return new OpencodeWorkerRuntime(config);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function validate(config: OpencodeRuntimeConfig): void {
|
|
27
|
-
const ref = config.workerRef;
|
|
28
|
-
if (!ref.workerId.trim()) {
|
|
29
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.workerId is required.');
|
|
30
|
-
}
|
|
31
|
-
if (!ref.controlPlaneUrl.trim()) {
|
|
32
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.controlPlaneUrl is required.');
|
|
33
|
-
}
|
|
34
|
-
if (!ref.region.trim()) {
|
|
35
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.region is required.');
|
|
36
|
-
}
|
|
37
|
-
if (!ref.allocationId.trim()) {
|
|
38
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.allocationId is required.');
|
|
39
|
-
}
|
|
40
|
-
if (!ref.workerServiceToken.trim()) {
|
|
41
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.workerServiceToken is required.');
|
|
42
|
-
}
|
|
43
|
-
if (!ref.workerControlPassword.trim()) {
|
|
44
|
-
throw new Error('OpencodeRuntimeConfig.workerRef.workerControlPassword is required.');
|
|
45
|
-
}
|
|
46
|
-
const caller = config.caller;
|
|
47
|
-
if (!caller.orgId.trim()) {
|
|
48
|
-
throw new Error('OpencodeRuntimeConfig.caller.orgId is required.');
|
|
49
|
-
}
|
|
50
|
-
if (!caller.projectId.trim()) {
|
|
51
|
-
throw new Error('OpencodeRuntimeConfig.caller.projectId is required.');
|
|
52
|
-
}
|
|
53
|
-
if (!caller.authToken.trim()) {
|
|
54
|
-
throw new Error('OpencodeRuntimeConfig.caller.authToken is required.');
|
|
55
|
-
}
|
|
56
|
-
}
|
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ── OpencodeWorkerRuntime — v1 WorkerRuntime impl ──
|
|
3
|
-
//
|
|
4
|
-
// A thin adapter that wraps the existing workspace-proxy control plane +
|
|
5
|
-
// opencode SSE stream behind the kernel `WorkerRuntime` interface.
|
|
6
|
-
//
|
|
7
|
-
// This impl deliberately does NOT import from `biomes/workspace-proxy/api/workspace-proxy/` or
|
|
8
|
-
// `apps/opencode-pool-api/` — the wire format is the contract. The same
|
|
9
|
-
// HTTP endpoints exposed by workspace-proxy are called by this driver
|
|
10
|
-
// from outside the pod (the orchestrator runs separately).
|
|
11
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
HookKind,
|
|
15
|
-
WorkerRuntimeKind,
|
|
16
|
-
type BuildBundleInput,
|
|
17
|
-
type BuildPodSpecInput,
|
|
18
|
-
type SessionBundle,
|
|
19
|
-
type WorkerRuntime,
|
|
20
|
-
type WorkerRuntimeCapabilityBits,
|
|
21
|
-
} from '@xemahq/kernel-contracts/worker-runtime';
|
|
22
|
-
import type { AbstractMount } from '@xemahq/kernel-contracts/workspace-storage';
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
buildOpencodeSessionBundle,
|
|
26
|
-
decodeOpencodeSessionBundle,
|
|
27
|
-
type OpencodeSessionBundlePayload,
|
|
28
|
-
} from './session-bundle';
|
|
29
|
-
import type { OpencodeRuntimeConfig, WorkerRef } from './worker-ref';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Wire connector kind a session must have bound in scope before the
|
|
33
|
-
* orchestrator may select this runtime. Surfaced as a string at the
|
|
34
|
-
* kernel boundary (see `WorkerRuntime.connectorKindRequired` —
|
|
35
|
-
* `packages/kernel/worker-runtime-contracts/src/lib/runtime.ts:84`).
|
|
36
|
-
*
|
|
37
|
-
* `'opencode-default'` is the LLM-gateway-backed connector that ships
|
|
38
|
-
* by default in every org, and the only one today. A future connector bound to
|
|
39
|
-
* a different runtime would expose its own kind here and select that runtime.
|
|
40
|
-
*/
|
|
41
|
-
export const OPENCODE_CONNECTOR_KIND = 'opencode-default';
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Upper bound for a single `PUT /control/session-bundle` apply (F2). Chosen
|
|
45
|
-
* comfortably above a legitimate cold apply — workspace-proxy materializes
|
|
46
|
-
* (~sub-second) then restarts opencode with a 30s health-wait ceiling, so a
|
|
47
|
-
* correct apply completes in well under a minute even on a loaded node — and
|
|
48
|
-
* far below the ~256s OS TCP read timeout that a stuck/black-holed handler
|
|
49
|
-
* would otherwise cost. Keeps a wedged apply from stalling the whole allocation
|
|
50
|
-
* for minutes.
|
|
51
|
-
*/
|
|
52
|
-
const APPLY_SESSION_BUNDLE_TIMEOUT_MS = 120_000;
|
|
53
|
-
|
|
54
|
-
const OPENCODE_CAPABILITIES: WorkerRuntimeCapabilityBits = {
|
|
55
|
-
hotMcp: true,
|
|
56
|
-
hotModelSwap: true,
|
|
57
|
-
sessionFork: true,
|
|
58
|
-
subAgentCrud: true,
|
|
59
|
-
hooks: [
|
|
60
|
-
HookKind.ToolExecuteBefore,
|
|
61
|
-
HookKind.ToolExecuteAfter,
|
|
62
|
-
HookKind.SessionIdle,
|
|
63
|
-
HookKind.SessionStart,
|
|
64
|
-
],
|
|
65
|
-
previewSupervisor: true,
|
|
66
|
-
gatewayAttributedCalls: true,
|
|
67
|
-
llmEndpointOverride: true,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Shape the K8s scheduler in `biomes/workload-runtime/api/workload-runtime-api/src/scheduler/k8s/`
|
|
72
|
-
* narrows when `buildPodSpec` returns. The orchestrator stays
|
|
73
|
-
* runtime-agnostic; the scheduler is the only consumer that interprets
|
|
74
|
-
* these fields. Mirrors `WorkloadContainerSpecDto` keys.
|
|
75
|
-
*/
|
|
76
|
-
export interface OpencodePodSpec {
|
|
77
|
-
readonly runtimeKind: WorkerRuntimeKind;
|
|
78
|
-
readonly image: string;
|
|
79
|
-
readonly env: Readonly<Record<string, string>>;
|
|
80
|
-
readonly mounts: readonly AbstractMount[];
|
|
81
|
-
readonly region?: string;
|
|
82
|
-
/**
|
|
83
|
-
* Translated scheduler-side hint. For k8s this becomes
|
|
84
|
-
* `nodeAffinity` on `topology.kubernetes.io/region`; for Docker it
|
|
85
|
-
* becomes a container label. The scheduler picks the right shape.
|
|
86
|
-
*/
|
|
87
|
-
readonly nodeAffinity?: {
|
|
88
|
-
readonly key: string;
|
|
89
|
-
readonly values: readonly string[];
|
|
90
|
-
};
|
|
91
|
-
readonly requestedCapabilities: BuildPodSpecInput['capabilities'];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The opencode-runtime adapter. ONE instance per (allocation, worker)
|
|
96
|
-
* — the worker identity is bound at construction so the kernel methods
|
|
97
|
-
* stay parameter-free per the contract.
|
|
98
|
-
*/
|
|
99
|
-
export class OpencodeWorkerRuntime implements WorkerRuntime {
|
|
100
|
-
readonly kind = WorkerRuntimeKind.Opencode;
|
|
101
|
-
readonly capabilities = OPENCODE_CAPABILITIES;
|
|
102
|
-
readonly connectorKindRequired = OPENCODE_CONNECTOR_KIND;
|
|
103
|
-
readonly autoCommitHookSupported = true;
|
|
104
|
-
|
|
105
|
-
private workerRef: WorkerRef;
|
|
106
|
-
private caller: OpencodeRuntimeConfig['caller'];
|
|
107
|
-
|
|
108
|
-
constructor(config: OpencodeRuntimeConfig) {
|
|
109
|
-
this.workerRef = config.workerRef;
|
|
110
|
-
this.caller = config.caller;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Translate the schedule-agnostic input to the OpencodePodSpec shape
|
|
115
|
-
* the K8s/Docker schedulers consume. The kernel contract types the
|
|
116
|
-
* return as `unknown` so the scheduler boundary is the only place that
|
|
117
|
-
* narrows. No I/O — pure transform.
|
|
118
|
-
*/
|
|
119
|
-
buildPodSpec(input: BuildPodSpecInput): Promise<OpencodePodSpec> {
|
|
120
|
-
const spec: OpencodePodSpec = {
|
|
121
|
-
runtimeKind: this.kind,
|
|
122
|
-
image: input.image,
|
|
123
|
-
env: input.env,
|
|
124
|
-
mounts: input.mounts,
|
|
125
|
-
...(input.region !== undefined ? { region: input.region } : {}),
|
|
126
|
-
...(input.region !== undefined
|
|
127
|
-
? {
|
|
128
|
-
nodeAffinity: {
|
|
129
|
-
key: 'topology.kubernetes.io/region',
|
|
130
|
-
values: [input.region],
|
|
131
|
-
},
|
|
132
|
-
}
|
|
133
|
-
: {}),
|
|
134
|
-
requestedCapabilities: input.capabilities,
|
|
135
|
-
};
|
|
136
|
-
return Promise.resolve(spec);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Compose a SessionBundle. The fingerprint short-circuits the
|
|
141
|
-
* workspace-proxy apply call on the next `applySessionBundle`.
|
|
142
|
-
*/
|
|
143
|
-
async buildSessionBundle(input: BuildBundleInput): Promise<SessionBundle> {
|
|
144
|
-
const { bundle } = buildOpencodeSessionBundle(
|
|
145
|
-
input,
|
|
146
|
-
this.workerRef.allocationId,
|
|
147
|
-
this.caller,
|
|
148
|
-
this.workerRef.bindingEpoch,
|
|
149
|
-
// Per-allocation worker service JWT minted by the orchestrator — the
|
|
150
|
-
// sole owner of this credential. Delivered on the orchestrator's
|
|
151
|
-
// initial applySessionBundle so `/run/xema/service-token` exists before
|
|
152
|
-
// working-file arming; the proxy never clears it on later re-applies.
|
|
153
|
-
this.workerRef.workerServiceToken,
|
|
154
|
-
);
|
|
155
|
-
return bundle;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Push a built bundle to the live worker.
|
|
160
|
-
*
|
|
161
|
-
* Endpoint: `PUT <controlPlaneUrl>/control/session-bundle`
|
|
162
|
-
* Source-of-truth at `biomes/workspace-proxy/api/workspace-proxy/src/handlers/control-plane.ts:377`.
|
|
163
|
-
*
|
|
164
|
-
* Response contract:
|
|
165
|
-
* - 200 `{ unchanged: true, ... }` ← fingerprint matched; no work done.
|
|
166
|
-
* - 200 `{ unchanged: false, applied: true, ... }` ← re-materialized.
|
|
167
|
-
* - 4xx/5xx ← failure; this method throws.
|
|
168
|
-
*/
|
|
169
|
-
async applySessionBundle(bundle: SessionBundle): Promise<void> {
|
|
170
|
-
const payload = decodeOpencodeSessionBundle(bundle);
|
|
171
|
-
const url = joinUrl(
|
|
172
|
-
this.workerRef.controlPlaneUrl,
|
|
173
|
-
'/control/session-bundle',
|
|
174
|
-
);
|
|
175
|
-
// F2 — bound the apply. workspace-proxy's `/control/session-bundle` handler
|
|
176
|
-
// is internally bounded (materialize + a 30s opencode-restart health wait +
|
|
177
|
-
// token injection), so a correct apply completes in well under a minute even
|
|
178
|
-
// on a cold node. Without an explicit signal a *stuck* handler (e.g. the
|
|
179
|
-
// worker pod deleted mid-apply, or a restart storm preempting the restart)
|
|
180
|
-
// black-holes the connection until the OS TCP read timeout (~256s), so the
|
|
181
|
-
// orchestrator burns ~4 minutes per doomed attempt before it can compensate
|
|
182
|
-
// and retry. An `AbortSignal.timeout` well above a legitimate cold apply but
|
|
183
|
-
// far below the kernel ceiling converts that into a fast, deterministic
|
|
184
|
-
// failure the saga can act on immediately. This is a bounded external-call
|
|
185
|
-
// timeout (allowed by the Constitution), NOT a sleep to mask a race.
|
|
186
|
-
let response: Response;
|
|
187
|
-
try {
|
|
188
|
-
response = await fetch(url, {
|
|
189
|
-
method: 'PUT',
|
|
190
|
-
headers: this.authHeaders(),
|
|
191
|
-
body: JSON.stringify(payload),
|
|
192
|
-
signal: AbortSignal.timeout(APPLY_SESSION_BUNDLE_TIMEOUT_MS),
|
|
193
|
-
});
|
|
194
|
-
} catch (err) {
|
|
195
|
-
// `AbortSignal.timeout` aborts with a `TimeoutError` DOMException; some
|
|
196
|
-
// runtimes surface the abort as `AbortError`. The only abort source here
|
|
197
|
-
// is our own timeout, so treat either as the bounded-apply timeout.
|
|
198
|
-
if (
|
|
199
|
-
err instanceof DOMException &&
|
|
200
|
-
(err.name === 'TimeoutError' || err.name === 'AbortError')
|
|
201
|
-
) {
|
|
202
|
-
throw new Error(
|
|
203
|
-
`applySessionBundle timed out after ${APPLY_SESSION_BUNDLE_TIMEOUT_MS}ms ` +
|
|
204
|
-
`waiting on ${url} — the worker control plane accepted the connection ` +
|
|
205
|
-
'but never responded (stuck handler / reaped pod). Failing fast so the ' +
|
|
206
|
-
'allocation saga can compensate and retry.',
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
throw err;
|
|
210
|
-
}
|
|
211
|
-
if (!response.ok) {
|
|
212
|
-
const body = await safeReadText(response);
|
|
213
|
-
throw new Error(
|
|
214
|
-
`applySessionBundle failed: HTTP ${response.status} from ${url} — ${body}`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
const parsed = (await response.json()) as ApplyBundleResponse;
|
|
218
|
-
assertApplyResponseOk(parsed, payload);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Pause the worker.
|
|
223
|
-
*
|
|
224
|
-
* Endpoint: `POST <controlPlaneUrl>/control/pause` — the wire shape is
|
|
225
|
-
* `200 { paused: true, state: { ... } }` (source at
|
|
226
|
-
* `biomes/workspace-proxy/api/workspace-proxy/src/handlers/control-pause-resume.ts`). The
|
|
227
|
-
* runtime carries the current bundle fingerprint + allocationId so
|
|
228
|
-
* workspace-proxy can stamp them on the pause marker; resume can
|
|
229
|
-
* then short-circuit if the bundle is unchanged.
|
|
230
|
-
*/
|
|
231
|
-
async pause(): Promise<void> {
|
|
232
|
-
const url = joinUrl(this.workerRef.controlPlaneUrl, '/control/pause');
|
|
233
|
-
const body: ControlPauseBody = {
|
|
234
|
-
allocationId: this.workerRef.allocationId,
|
|
235
|
-
};
|
|
236
|
-
const response = await fetch(url, {
|
|
237
|
-
method: 'POST',
|
|
238
|
-
headers: this.authHeaders(),
|
|
239
|
-
body: JSON.stringify(body),
|
|
240
|
-
});
|
|
241
|
-
if (!response.ok) {
|
|
242
|
-
const text = await safeReadText(response);
|
|
243
|
-
throw new Error(
|
|
244
|
-
`OpencodeWorkerRuntime.pause failed: HTTP ${response.status} from ${url} — ${text}`,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Resume a previously-paused worker.
|
|
251
|
-
*
|
|
252
|
-
* Endpoint: `POST <controlPlaneUrl>/control/resume` — the wire shape is
|
|
253
|
-
* `200 { resumed: true, state: { ... } }`. A 409
|
|
254
|
-
* `PAUSE_RESUME_FINGERPRINT_MISMATCH` from workspace-proxy means the
|
|
255
|
-
* caller must re-apply the bundle (`applySessionBundle`) before
|
|
256
|
-
* retrying — surfaced as a typed error here so the orchestrator can
|
|
257
|
-
* branch.
|
|
258
|
-
*/
|
|
259
|
-
async resume(): Promise<void> {
|
|
260
|
-
const url = joinUrl(this.workerRef.controlPlaneUrl, '/control/resume');
|
|
261
|
-
const response = await fetch(url, {
|
|
262
|
-
method: 'POST',
|
|
263
|
-
headers: this.authHeaders(),
|
|
264
|
-
body: JSON.stringify({}),
|
|
265
|
-
});
|
|
266
|
-
if (!response.ok) {
|
|
267
|
-
const text = await safeReadText(response);
|
|
268
|
-
throw new Error(
|
|
269
|
-
`OpencodeWorkerRuntime.resume failed: HTTP ${response.status} from ${url} — ${text}`,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private authHeaders(): Record<string, string> {
|
|
275
|
-
// workspace-proxy guards every control/session route with HTTP Basic
|
|
276
|
-
// auth (`opencode:<OPENCODE_SERVER_PASSWORD>`); a Bearer token is
|
|
277
|
-
// rejected (scheme must be `Basic`). The worker's egress JWT
|
|
278
|
-
// (workerServiceToken) is a DIFFERENT credential delivered into the pod
|
|
279
|
-
// separately — never the inbound control credential.
|
|
280
|
-
const encoded = Buffer.from(
|
|
281
|
-
`opencode:${this.workerRef.workerControlPassword}`,
|
|
282
|
-
).toString('base64');
|
|
283
|
-
return {
|
|
284
|
-
Authorization: `Basic ${encoded}`,
|
|
285
|
-
'Content-Type': 'application/json',
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Body shape for `POST /control/pause`. The fingerprint slot is left
|
|
292
|
-
* undefined here because the runtime adapter doesn't track the last
|
|
293
|
-
* applied bundle fingerprint — the orchestrator stamps it via
|
|
294
|
-
* `applySessionBundle` and workspace-proxy persists it in the marker.
|
|
295
|
-
*/
|
|
296
|
-
interface ControlPauseBody {
|
|
297
|
-
readonly allocationId: string;
|
|
298
|
-
readonly fingerprint?: string;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
interface ApplyBundleResponse {
|
|
302
|
-
readonly unchanged?: boolean;
|
|
303
|
-
readonly applied?: boolean;
|
|
304
|
-
readonly state?: {
|
|
305
|
-
readonly allocationId?: string | null;
|
|
306
|
-
readonly setupAt?: string | null;
|
|
307
|
-
};
|
|
308
|
-
readonly error?: string;
|
|
309
|
-
readonly status?: string;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function assertApplyResponseOk(
|
|
313
|
-
parsed: ApplyBundleResponse,
|
|
314
|
-
payload: OpencodeSessionBundlePayload,
|
|
315
|
-
): void {
|
|
316
|
-
if (parsed.unchanged === true) return;
|
|
317
|
-
if (parsed.applied === true) {
|
|
318
|
-
const stateAllocationId = parsed.state?.allocationId ?? null;
|
|
319
|
-
if (stateAllocationId !== payload.allocationId) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
`applySessionBundle returned a state allocationId (${String(stateAllocationId)}) that disagrees with the requested allocationId (${payload.allocationId}). The worker rebound to another session — fail fast.`,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
throw new Error(
|
|
327
|
-
`applySessionBundle: workspace-proxy returned an unexpected shape — ${JSON.stringify(parsed)}`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
async function safeReadText(response: Response): Promise<string> {
|
|
332
|
-
try {
|
|
333
|
-
return await response.text();
|
|
334
|
-
} catch {
|
|
335
|
-
return '<unreadable response body>';
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function joinUrl(base: string, path: string): string {
|
|
340
|
-
const trimmedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
341
|
-
const prefixedPath = path.startsWith('/') ? path : `/${path}`;
|
|
342
|
-
return `${trimmedBase}${prefixedPath}`;
|
|
343
|
-
}
|
|
344
|
-
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ── Opencode session-bundle encoding ──
|
|
3
|
-
//
|
|
4
|
-
// Translates the kernel-shape `BuildBundleInput` into the on-the-wire
|
|
5
|
-
// payload that workspace-proxy's `PUT /control/session-bundle` handler
|
|
6
|
-
// expects (defined at `biomes/workspace-proxy/api/workspace-proxy/src/handlers/control-plane.ts:816`
|
|
7
|
-
// — `interface BundlePayload`).
|
|
8
|
-
//
|
|
9
|
-
// `staticFingerprint` is the reusable runtime-template compatibility
|
|
10
|
-
// identity. `bindingRevision` / `bindingEpoch` identify the tenant/session
|
|
11
|
-
// state applied after a compatible worker is claimed.
|
|
12
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
-
|
|
14
|
-
import { createHash } from 'node:crypto';
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
BuildBundleInput,
|
|
18
|
-
SessionBundle,
|
|
19
|
-
} from '@xemahq/kernel-contracts/worker-runtime';
|
|
20
|
-
|
|
21
|
-
import type { BundleCallerContext } from './worker-ref';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Sub-agent entry as the workspace-proxy bundle handler expects it.
|
|
25
|
-
* Workspace-proxy validates this shape at runtime; mismatched fields
|
|
26
|
-
* fail with HTTP 400. Keep field names byte-for-byte aligned with the
|
|
27
|
-
* `BundlePayload.subAgents` parser.
|
|
28
|
-
*/
|
|
29
|
-
export interface OpencodeBundleSubAgent {
|
|
30
|
-
readonly slug: string;
|
|
31
|
-
readonly alias?: string;
|
|
32
|
-
readonly stageKey?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Fully-formed JSON body for `PUT /control/session-bundle`. Mirrors
|
|
37
|
-
* `biomes/workspace-proxy/api/workspace-proxy/src/handlers/control-plane.ts:816` (`BundlePayload`).
|
|
38
|
-
*/
|
|
39
|
-
export interface OpencodeSessionBundlePayload {
|
|
40
|
-
readonly allocationId: string;
|
|
41
|
-
readonly caller: BundleCallerContext;
|
|
42
|
-
readonly opencodeConfigBase64: string;
|
|
43
|
-
readonly primaryAgent: { readonly slug: string };
|
|
44
|
-
readonly subAgents: readonly OpencodeBundleSubAgent[];
|
|
45
|
-
readonly modelOverride?: string;
|
|
46
|
-
readonly mcpConfig?: string;
|
|
47
|
-
/**
|
|
48
|
-
* Per-allocation worker service JWT. workspace-proxy persists it to
|
|
49
|
-
* `/run/xema/service-token`, where the in-worker `xema_*` tools + the KB
|
|
50
|
-
* working-file adapter read it to authenticate their callbacks. The
|
|
51
|
-
* orchestrator is the sole minter/owner and delivers it on its initial
|
|
52
|
-
* `applySessionBundle`; the proxy writes-when-present and NEVER clears it,
|
|
53
|
-
* so later agent-session-api re-applies (which omit it) leave the token
|
|
54
|
-
* intact. Deliberately EXCLUDED from both hashes: raw credentials must
|
|
55
|
-
* never enter compatibility or binding identities.
|
|
56
|
-
*/
|
|
57
|
-
readonly serviceToken?: string;
|
|
58
|
-
readonly staticFingerprint: string;
|
|
59
|
-
readonly bindingRevision: string;
|
|
60
|
-
readonly bindingEpoch: number;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Stable canonical JSON encoder. Keys are sorted alphabetically at every
|
|
65
|
-
* object level; undefined values are stripped. The output is the input
|
|
66
|
-
* to the SHA-256 fingerprint and MUST be deterministic.
|
|
67
|
-
*/
|
|
68
|
-
function canonicalize(value: unknown): unknown {
|
|
69
|
-
if (value === null || typeof value !== 'object') return value;
|
|
70
|
-
if (Array.isArray(value)) return value.map((v) => canonicalize(v));
|
|
71
|
-
const obj = value as Record<string, unknown>;
|
|
72
|
-
const out: Record<string, unknown> = {};
|
|
73
|
-
for (const key of Object.keys(obj).sort()) {
|
|
74
|
-
const v = obj[key];
|
|
75
|
-
if (v === undefined) continue;
|
|
76
|
-
out[key] = canonicalize(v);
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Compute the static compatibility fingerprint. It MUST be stable across
|
|
83
|
-
* structurally-identical inputs and MUST change when ANY of:
|
|
84
|
-
* - primary agent slug,
|
|
85
|
-
* - sub-agent set (slug + alias + stageKey),
|
|
86
|
-
* - opencode config bytes,
|
|
87
|
-
* - MCP config blob,
|
|
88
|
-
* differs.
|
|
89
|
-
*
|
|
90
|
-
* Per-invocation model selection is binding state and is intentionally
|
|
91
|
-
* excluded from this runtime-template identity.
|
|
92
|
-
*/
|
|
93
|
-
export function computeStaticBundleFingerprint(
|
|
94
|
-
input: BuildBundleInput,
|
|
95
|
-
): string {
|
|
96
|
-
const sortedSubAgents = [...input.subAgents].sort();
|
|
97
|
-
const canonical = canonicalize({
|
|
98
|
-
primaryAgent: input.primaryAgent,
|
|
99
|
-
subAgents: sortedSubAgents,
|
|
100
|
-
// Static key stays `opencodeConfigBase64` to match workspace-proxy's
|
|
101
|
-
// wire-side fingerprint; only the kernel input field was renamed.
|
|
102
|
-
opencodeConfigBase64: input.runtimeConfigBase64,
|
|
103
|
-
mcpConfig: input.mcpConfig,
|
|
104
|
-
});
|
|
105
|
-
const hash = createHash('sha256');
|
|
106
|
-
hash.update(JSON.stringify(canonical));
|
|
107
|
-
return hash.digest('hex');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Hash only non-secret tenant/session identifiers. Actor and service tokens
|
|
112
|
-
* are deliberately absent so credential rotation cannot leak into hashes.
|
|
113
|
-
*/
|
|
114
|
-
export function computeSessionBindingRevision(
|
|
115
|
-
allocationId: string,
|
|
116
|
-
caller: BundleCallerContext,
|
|
117
|
-
): string {
|
|
118
|
-
return createHash('sha256')
|
|
119
|
-
.update(
|
|
120
|
-
JSON.stringify(
|
|
121
|
-
canonicalize({
|
|
122
|
-
allocationId,
|
|
123
|
-
orgId: caller.orgId,
|
|
124
|
-
projectId: caller.projectId,
|
|
125
|
-
sessionId: caller.sessionId,
|
|
126
|
-
}),
|
|
127
|
-
),
|
|
128
|
-
)
|
|
129
|
-
.digest('hex');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Build the wire payload + opaque `SessionBundle` the kernel contract
|
|
134
|
-
* exposes. The payload itself is base64-encoded so `SessionBundle` stays
|
|
135
|
-
* an opaque blob at the kernel boundary (per the contract docs at
|
|
136
|
-
* `packages/kernel/worker-runtime-contracts/src/lib/runtime.ts:66`).
|
|
137
|
-
*/
|
|
138
|
-
export function buildOpencodeSessionBundle(
|
|
139
|
-
input: BuildBundleInput,
|
|
140
|
-
allocationId: string,
|
|
141
|
-
caller: BundleCallerContext,
|
|
142
|
-
bindingEpoch: number,
|
|
143
|
-
workerServiceToken: string | undefined,
|
|
144
|
-
): {
|
|
145
|
-
readonly payload: OpencodeSessionBundlePayload;
|
|
146
|
-
readonly bundle: SessionBundle;
|
|
147
|
-
} {
|
|
148
|
-
if (!input.primaryAgent.trim()) {
|
|
149
|
-
throw new Error('BuildBundleInput.primaryAgent must be a non-empty slug.');
|
|
150
|
-
}
|
|
151
|
-
if (!allocationId.trim()) {
|
|
152
|
-
throw new Error('allocationId must be a non-empty string.');
|
|
153
|
-
}
|
|
154
|
-
if (!Number.isSafeInteger(bindingEpoch) || bindingEpoch < 1) {
|
|
155
|
-
throw new Error('bindingEpoch must be a positive safe integer.');
|
|
156
|
-
}
|
|
157
|
-
const opencodeConfigBase64 = input.runtimeConfigBase64 ?? '';
|
|
158
|
-
if (!opencodeConfigBase64) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
'BuildBundleInput.runtimeConfigBase64 is required by the opencode runtime — the workspace-proxy bundle handler treats an empty config as a 400.',
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
const staticFingerprint = computeStaticBundleFingerprint(input);
|
|
164
|
-
const bindingRevision = computeSessionBindingRevision(allocationId, caller);
|
|
165
|
-
|
|
166
|
-
const subAgents: readonly OpencodeBundleSubAgent[] = input.subAgents.map(
|
|
167
|
-
(slug) => ({ slug }),
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
// Build the payload, stripping undefined optionals so canonical JSON
|
|
171
|
-
// diff stays stable and the workspace-proxy parser does not error on
|
|
172
|
-
// an explicit `undefined`. The intermediate `Mutable<>` shape lets us
|
|
173
|
-
// add only the optional fields that are actually set, which keeps
|
|
174
|
-
// `exactOptionalPropertyTypes: true` happy.
|
|
175
|
-
const payload: OpencodeSessionBundlePayload = {
|
|
176
|
-
allocationId,
|
|
177
|
-
caller,
|
|
178
|
-
opencodeConfigBase64,
|
|
179
|
-
primaryAgent: { slug: input.primaryAgent },
|
|
180
|
-
subAgents,
|
|
181
|
-
staticFingerprint,
|
|
182
|
-
bindingRevision,
|
|
183
|
-
bindingEpoch,
|
|
184
|
-
...(input.modelOverride === undefined
|
|
185
|
-
? {}
|
|
186
|
-
: { modelOverride: input.modelOverride }),
|
|
187
|
-
...(input.mcpConfig === undefined ? {} : { mcpConfig: input.mcpConfig }),
|
|
188
|
-
// Credential — carried on the payload but NOT in either hash, so a token
|
|
189
|
-
// refresh never churns compatibility or binding identity. Omitted when
|
|
190
|
-
// empty so the proxy's write-when-present path
|
|
191
|
-
// is a no-op rather than clobbering an existing token with "".
|
|
192
|
-
...(workerServiceToken ? { serviceToken: workerServiceToken } : {}),
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const payloadJson = JSON.stringify(payload);
|
|
196
|
-
const payloadBase64 = Buffer.from(payloadJson, 'utf8').toString('base64');
|
|
197
|
-
return {
|
|
198
|
-
payload,
|
|
199
|
-
bundle: { fingerprint: staticFingerprint, payloadBase64 },
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Decode a kernel `SessionBundle` back into the opencode-specific payload.
|
|
205
|
-
* Used by `applySessionBundle` so the runtime stays stateless between
|
|
206
|
-
* `buildSessionBundle` and `applySessionBundle` (the kernel contract
|
|
207
|
-
* does not pass the input through, only the opaque bundle).
|
|
208
|
-
*/
|
|
209
|
-
export function decodeOpencodeSessionBundle(
|
|
210
|
-
bundle: SessionBundle,
|
|
211
|
-
): OpencodeSessionBundlePayload {
|
|
212
|
-
let parsed: unknown;
|
|
213
|
-
try {
|
|
214
|
-
const json = Buffer.from(bundle.payloadBase64, 'base64').toString('utf8');
|
|
215
|
-
parsed = JSON.parse(json);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
218
|
-
throw new Error(
|
|
219
|
-
`SessionBundle.payloadBase64 is not a valid base64-encoded JSON object: ${msg}`,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
223
|
-
throw new Error('SessionBundle payload must decode to a JSON object.');
|
|
224
|
-
}
|
|
225
|
-
const obj = parsed as { readonly staticFingerprint?: unknown };
|
|
226
|
-
if (obj.staticFingerprint !== bundle.fingerprint) {
|
|
227
|
-
throw new Error(
|
|
228
|
-
`SessionBundle.fingerprint (${bundle.fingerprint}) disagrees with the encoded payload.staticFingerprint (${String(obj.staticFingerprint)}). Refusing to apply a tampered bundle.`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
return parsed as OpencodeSessionBundlePayload;
|
|
232
|
-
}
|
package/src/lib/worker-ref.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ── Opencode runtime — worker reference & runtime config ──
|
|
3
|
-
//
|
|
4
|
-
// The kernel `WorkerRuntime` contract intentionally hides the target-worker
|
|
5
|
-
// identity inside the driver instance. Each opencode runtime is constructed
|
|
6
|
-
// for ONE allocated worker pod; the `WorkerRef` is that identity. This
|
|
7
|
-
// shape MAY graduate to `@xemahq/kernel-contracts/worker-runtime` once a second
|
|
8
|
-
// runtime impl confirms the fields it actually needs.
|
|
9
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Identifies a single live worker pod for an opencode runtime instance.
|
|
13
|
-
* Constructed by the orchestrator after `workload-runtime-api`
|
|
14
|
-
* schedule + ready callbacks resolve.
|
|
15
|
-
*/
|
|
16
|
-
export interface WorkerRef {
|
|
17
|
-
/** Stable id assigned by `workload-runtime-api` at schedule time. */
|
|
18
|
-
readonly workerId: string;
|
|
19
|
-
/**
|
|
20
|
-
* Base URL of the workspace-proxy control plane running INSIDE the pod
|
|
21
|
-
* (cluster-internal). e.g. `https://workspace-proxy.<ns>.svc.cluster.local:<port>`.
|
|
22
|
-
* The opencode runtime never talks to opencode directly — every wire call
|
|
23
|
-
* lands on workspace-proxy, which proxies into the in-pod opencode server.
|
|
24
|
-
*/
|
|
25
|
-
readonly controlPlaneUrl: string;
|
|
26
|
-
/** Region the worker is scheduled in; surfaced for observability. */
|
|
27
|
-
readonly region: string;
|
|
28
|
-
/**
|
|
29
|
-
* Allocation id minted by the orchestrator. Included on every
|
|
30
|
-
* `/control/session-bundle` PUT so workspace-proxy can detect a
|
|
31
|
-
* re-bind to a different session and refuse stale bundles.
|
|
32
|
-
*/
|
|
33
|
-
readonly allocationId: string;
|
|
34
|
-
/**
|
|
35
|
-
* Monotonic generation of the allocation binding on this worker. Fresh
|
|
36
|
-
* workers start at 1; warm-prime reserves epoch 0. A worker reused for a
|
|
37
|
-
* different allocation must receive a strictly greater epoch.
|
|
38
|
-
*/
|
|
39
|
-
readonly bindingEpoch: number;
|
|
40
|
-
/**
|
|
41
|
-
* Worker-service JWT (Keycloak) — the worker's OWN egress identity,
|
|
42
|
-
* delivered into the pod via `PUT /control/service-token` and presented
|
|
43
|
-
* by the worker on its OUTBOUND calls. It is NOT the credential for
|
|
44
|
-
* inbound control-plane auth (see `workerControlPassword`). Refreshing
|
|
45
|
-
* it before expiry is the orchestrator's job; that path is NOT exposed
|
|
46
|
-
* by this runtime driver.
|
|
47
|
-
*/
|
|
48
|
-
readonly workerServiceToken: string;
|
|
49
|
-
/**
|
|
50
|
-
* Per-allocation control-plane password. workspace-proxy guards every
|
|
51
|
-
* `/control/*` + `/session/*` route with HTTP Basic auth
|
|
52
|
-
* (`opencode:<password>`) seeded from its `OPENCODE_SERVER_PASSWORD`
|
|
53
|
-
* env, which the orchestrator generated for this allocation. The runtime
|
|
54
|
-
* presents it as the Basic credential on every inbound call — a JWT in a
|
|
55
|
-
* `Bearer` header is rejected by the proxy (scheme must be `Basic`).
|
|
56
|
-
*/
|
|
57
|
-
readonly workerControlPassword: string;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Per-runtime-instance configuration the factory accepts.
|
|
62
|
-
*/
|
|
63
|
-
export interface OpencodeRuntimeConfig {
|
|
64
|
-
readonly workerRef: WorkerRef;
|
|
65
|
-
/**
|
|
66
|
-
* Optional caller context written into the session-bundle PUT body.
|
|
67
|
-
* The workspace-proxy bundle handler refuses calls without these
|
|
68
|
-
* three identifiers, so callers MUST provide them at runtime
|
|
69
|
-
* construction (see `BundlePayload.caller` at
|
|
70
|
-
* `biomes/workspace-proxy/api/workspace-proxy/src/handlers/control-plane.ts:818`).
|
|
71
|
-
*/
|
|
72
|
-
readonly caller: BundleCallerContext;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Authentication + scope context the bundle handler stamps onto the
|
|
77
|
-
* worker. `authToken` is the END-USER (turn-initiator) actor JWT — the
|
|
78
|
-
* service token in `WorkerRef` is presented in the HTTP Authorization
|
|
79
|
-
* header, the actor JWT travels in the JSON body.
|
|
80
|
-
*/
|
|
81
|
-
export interface BundleCallerContext {
|
|
82
|
-
readonly orgId: string;
|
|
83
|
-
readonly projectId: string;
|
|
84
|
-
readonly sessionId?: string;
|
|
85
|
-
readonly authToken: string;
|
|
86
|
-
}
|