@rigkit/sdk 0.1.8
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/README.md +19 -0
- package/package.json +41 -0
- package/src/cli.ts +116 -0
- package/src/host.ts +46 -0
- package/src/index.test.ts +53 -0
- package/src/index.ts +84 -0
- package/src/runtime/api-handlers.ts +166 -0
- package/src/runtime/api.ts +69 -0
- package/src/runtime/app.test.ts +924 -0
- package/src/runtime/app.ts +63 -0
- package/src/runtime/cli.ts +115 -0
- package/src/runtime/control.ts +163 -0
- package/src/runtime/errors.ts +108 -0
- package/src/runtime/index.ts +81 -0
- package/src/runtime/openapi.ts +5 -0
- package/src/runtime/operations.ts +267 -0
- package/src/runtime/protocol.ts +193 -0
- package/src/runtime/runs.ts +292 -0
- package/src/runtime/server.ts +182 -0
- package/src/runtime/sessions.ts +257 -0
- package/src/runtime/state.ts +12 -0
- package/src/runtime/token.ts +16 -0
- package/src/runtime/types.ts +35 -0
- package/src/runtime/version.ts +1 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
import { createRuntimeControlApiHandler } from "./api-handlers.ts";
|
|
7
|
+
import { createRuntimeApp } from "./app.ts";
|
|
8
|
+
import {
|
|
9
|
+
RuntimeOperationValidationError,
|
|
10
|
+
} from "./errors.ts";
|
|
11
|
+
import { loadEngine, operationManifestFor } from "./operations.ts";
|
|
12
|
+
import {
|
|
13
|
+
HostCommandRequestSchema,
|
|
14
|
+
HostCommandResultSchema,
|
|
15
|
+
HostResponseSchema,
|
|
16
|
+
RUNTIME_API_VERSION,
|
|
17
|
+
RUNTIME_PROTOCOL_HASH,
|
|
18
|
+
} from "./protocol.ts";
|
|
19
|
+
import { createRun, createRunStore } from "./runs.ts";
|
|
20
|
+
import { serveRuntime, serveRuntimeEffect } from "./server.ts";
|
|
21
|
+
import type { RuntimeContext } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
describe("runtime HTTP app", () => {
|
|
24
|
+
test("exposes runtime metadata with protocol headers", async () => {
|
|
25
|
+
const app = createRuntimeApp(testContext(), createRunStore());
|
|
26
|
+
|
|
27
|
+
const unauthenticated = await app.request("/runtime");
|
|
28
|
+
expect(unauthenticated.status).toBe(401);
|
|
29
|
+
|
|
30
|
+
const response = await app.request("/runtime", {
|
|
31
|
+
headers: { authorization: "Bearer test-token" },
|
|
32
|
+
});
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
|
|
35
|
+
expect(response.status).toBe(200);
|
|
36
|
+
expect(response.headers.get("x-rigkit-api-version")).toBe(String(RUNTIME_API_VERSION));
|
|
37
|
+
expect(response.headers.get("x-rigkit-protocol-hash")).toBe(RUNTIME_PROTOCOL_HASH);
|
|
38
|
+
expect(body.apiVersion).toBe(RUNTIME_API_VERSION);
|
|
39
|
+
expect(body.protocolHash).toBe(RUNTIME_PROTOCOL_HASH);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("serves control endpoints through Effect HttpApiBuilder handlers", async () => {
|
|
43
|
+
const { handler, dispose } = createRuntimeControlApiHandler(testContext(), createRunStore());
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const unauthenticated = await handler(new Request("http://runtime.local/runtime"));
|
|
47
|
+
expect(unauthenticated.status).toBe(401);
|
|
48
|
+
expect(unauthenticated.headers.get("x-rigkit-api-version")).toBe(String(RUNTIME_API_VERSION));
|
|
49
|
+
|
|
50
|
+
const runtimeResponse = await handler(new Request("http://runtime.local/runtime", {
|
|
51
|
+
headers: { authorization: "Bearer test-token" },
|
|
52
|
+
}));
|
|
53
|
+
const runtimeBody = await runtimeResponse.json();
|
|
54
|
+
expect(runtimeResponse.status).toBe(200);
|
|
55
|
+
expect(runtimeResponse.headers.get("x-rigkit-protocol-hash")).toBe(RUNTIME_PROTOCOL_HASH);
|
|
56
|
+
expect(runtimeBody.apiVersion).toBe(RUNTIME_API_VERSION);
|
|
57
|
+
|
|
58
|
+
const missingRun = await handler(new Request("http://runtime.local/runs/missing", {
|
|
59
|
+
headers: { authorization: "Bearer test-token" },
|
|
60
|
+
}));
|
|
61
|
+
const missingRunBody = await missingRun.json();
|
|
62
|
+
expect(missingRun.status).toBe(404);
|
|
63
|
+
expect(missingRunBody.error.message).toBe("Unknown run missing");
|
|
64
|
+
} finally {
|
|
65
|
+
await dispose();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("resolves host responses through Effect HttpApiBuilder handlers", async () => {
|
|
70
|
+
const store = createRunStore();
|
|
71
|
+
const run = createRun("needs-host", {});
|
|
72
|
+
run.pendingHostRequestIds.add("host_req_test");
|
|
73
|
+
store.runs.set(run.id, run);
|
|
74
|
+
|
|
75
|
+
let resolved: unknown;
|
|
76
|
+
store.hostResponses.set("host_req_test", {
|
|
77
|
+
resolve: (value) => {
|
|
78
|
+
resolved = value;
|
|
79
|
+
},
|
|
80
|
+
reject: (error) => {
|
|
81
|
+
throw error;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { handler, dispose } = createRuntimeControlApiHandler(testContext(), store);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await handler(new Request("http://runtime.local/host-responses/host_req_test", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: "Bearer test-token",
|
|
92
|
+
"content-type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({ result: { ok: true } }),
|
|
95
|
+
}));
|
|
96
|
+
const body = await response.json();
|
|
97
|
+
|
|
98
|
+
expect(response.status).toBe(200);
|
|
99
|
+
expect(body).toEqual({ ok: true });
|
|
100
|
+
expect(resolved).toEqual({ ok: true });
|
|
101
|
+
expect(store.hostResponses.has("host_req_test")).toBe(false);
|
|
102
|
+
expect(run.pendingHostRequestIds.has("host_req_test")).toBe(false);
|
|
103
|
+
} finally {
|
|
104
|
+
await dispose();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("serveRuntimeEffect stops the runtime server when its scope closes", async () => {
|
|
109
|
+
const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-effect-"));
|
|
110
|
+
let url = "";
|
|
111
|
+
let closed: Promise<void> | undefined;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await Effect.runPromise(Effect.scoped(
|
|
115
|
+
Effect.flatMap(
|
|
116
|
+
serveRuntimeEffect({
|
|
117
|
+
projectId: "test-project",
|
|
118
|
+
projectDir: root,
|
|
119
|
+
configPath: join(root, "rig.config.ts"),
|
|
120
|
+
handlePath: join(root, "runtime.json"),
|
|
121
|
+
tokenPath: join(root, "runtime.token"),
|
|
122
|
+
token: "test-token",
|
|
123
|
+
idleMs: 60_000,
|
|
124
|
+
}),
|
|
125
|
+
(server) => Effect.promise(async () => {
|
|
126
|
+
url = server.url;
|
|
127
|
+
closed = server.closed;
|
|
128
|
+
const response = await fetch(new URL("/health", server.url), {
|
|
129
|
+
headers: { connection: "close" },
|
|
130
|
+
});
|
|
131
|
+
expect(response.status).toBe(200);
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
));
|
|
135
|
+
|
|
136
|
+
await closed;
|
|
137
|
+
await expect(fetch(new URL("/health", url), {
|
|
138
|
+
headers: { connection: "close" },
|
|
139
|
+
})).rejects.toThrow();
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(root, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("shutdown resolves the runtime server closed promise", async () => {
|
|
146
|
+
const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-shutdown-"));
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const server = await serveRuntime({
|
|
150
|
+
projectId: "test-project",
|
|
151
|
+
projectDir: root,
|
|
152
|
+
configPath: join(root, "rig.config.ts"),
|
|
153
|
+
handlePath: join(root, "runtime.json"),
|
|
154
|
+
tokenPath: join(root, "runtime.token"),
|
|
155
|
+
token: "test-token",
|
|
156
|
+
idleMs: 60_000,
|
|
157
|
+
});
|
|
158
|
+
const response = await fetch(new URL("/shutdown", server.url), {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { authorization: "Bearer test-token" },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(response.status).toBe(200);
|
|
164
|
+
await server.closed;
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("returns structured validation errors", async () => {
|
|
171
|
+
const app = createRuntimeApp(testContext(), createRunStore());
|
|
172
|
+
|
|
173
|
+
const response = await app.request("/runs", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
authorization: "Bearer test-token",
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({ input: {} }),
|
|
180
|
+
});
|
|
181
|
+
const body = await response.json();
|
|
182
|
+
|
|
183
|
+
expect(response.status).toBe(400);
|
|
184
|
+
expect(body.error.message).toContain("operation");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("serves a structured OpenAPI control-plane document", async () => {
|
|
188
|
+
const app = createRuntimeApp(testContext(), createRunStore());
|
|
189
|
+
|
|
190
|
+
const response = await app.request("/openapi.json", {
|
|
191
|
+
headers: { authorization: "Bearer test-token" },
|
|
192
|
+
});
|
|
193
|
+
const body = await response.json() as any;
|
|
194
|
+
|
|
195
|
+
expect(response.status).toBe(200);
|
|
196
|
+
expect(body.openapi).toBe("3.1.0");
|
|
197
|
+
expect(body.security).toEqual([{ bearerAuth: [] }]);
|
|
198
|
+
expect(body.paths["/health"].get.security).toEqual([]);
|
|
199
|
+
expect(body.paths["/runs"].post.requestBody.content["application/json"].schema).toEqual({
|
|
200
|
+
$ref: "#/components/schemas/RunOperationRequest",
|
|
201
|
+
});
|
|
202
|
+
expect(body.paths["/runs/{runId}"].get.parameters).toEqual([
|
|
203
|
+
{
|
|
204
|
+
name: "runId",
|
|
205
|
+
in: "path",
|
|
206
|
+
required: true,
|
|
207
|
+
schema: { type: "string" },
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
expect(body.paths["/host-responses/{requestId}"].post.requestBody.content["application/json"].schema).toEqual({
|
|
211
|
+
$ref: "#/components/schemas/HostResponse",
|
|
212
|
+
});
|
|
213
|
+
expect(body.components.securitySchemes.bearerAuth).toEqual({
|
|
214
|
+
type: "http",
|
|
215
|
+
scheme: "bearer",
|
|
216
|
+
});
|
|
217
|
+
expect(body.components.schemas.RuntimeOperation.required).toContain("inputSchema");
|
|
218
|
+
expect(body.components.schemas.Workspace.required).toContain("data");
|
|
219
|
+
expect(body.components.schemas.OperationsManifest.required).toEqual([
|
|
220
|
+
"hostMethods",
|
|
221
|
+
"hostCapabilities",
|
|
222
|
+
"operations",
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("accepts interactive host command protocol payloads", () => {
|
|
227
|
+
expect(HostCommandRequestSchema.parse({
|
|
228
|
+
argv: ["ssh", "root@127.0.0.1"],
|
|
229
|
+
mode: "interactive",
|
|
230
|
+
}).mode).toBe("interactive");
|
|
231
|
+
expect(HostCommandResultSchema.parse({
|
|
232
|
+
exitCode: 0,
|
|
233
|
+
stdout: null,
|
|
234
|
+
stderr: null,
|
|
235
|
+
})).toEqual({
|
|
236
|
+
exitCode: 0,
|
|
237
|
+
stdout: null,
|
|
238
|
+
stderr: null,
|
|
239
|
+
});
|
|
240
|
+
expect(HostResponseSchema.parse({
|
|
241
|
+
error: { code: "HOST_CAPABILITY_FAILED", message: "cmux is not running" },
|
|
242
|
+
})).toEqual({
|
|
243
|
+
error: { code: "HOST_CAPABILITY_FAILED", message: "cmux is not running" },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("projects config-defined operations into the runtime manifest", () => {
|
|
248
|
+
const manifest = operationManifestFor({
|
|
249
|
+
listWorkflows: () => [{ name: "test" }],
|
|
250
|
+
listRuntimeOperations: () => [
|
|
251
|
+
{
|
|
252
|
+
workflow: "",
|
|
253
|
+
id: "ssh",
|
|
254
|
+
source: "core",
|
|
255
|
+
kind: "command",
|
|
256
|
+
title: "SSH",
|
|
257
|
+
description: "Get an SSH command",
|
|
258
|
+
requiredHostMethods: [{ id: "host.command.run", modes: ["interactive"] }],
|
|
259
|
+
inputFields: [
|
|
260
|
+
{ kind: "string", name: "workflow", required: false },
|
|
261
|
+
{ kind: "string", name: "workspaceOrVmId", position: 0, required: true },
|
|
262
|
+
{ kind: "string", name: "user", required: false },
|
|
263
|
+
{ kind: "boolean", name: "print", required: false, defaultValue: false },
|
|
264
|
+
],
|
|
265
|
+
cli: {
|
|
266
|
+
positionals: [{ name: "workspaceOrVmId", index: 0 }],
|
|
267
|
+
options: [
|
|
268
|
+
{ name: "workflow", flag: "--workflow" },
|
|
269
|
+
{ name: "user", flag: "--user" },
|
|
270
|
+
{ name: "print", flag: "--print", type: "boolean" },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
workflow: "test",
|
|
276
|
+
id: "open",
|
|
277
|
+
source: "config",
|
|
278
|
+
title: "Open",
|
|
279
|
+
description: "Open a workspace",
|
|
280
|
+
createsWorkspace: false,
|
|
281
|
+
requiredHostMethods: [{ id: "host.command.run", modes: ["interactive"] }],
|
|
282
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
283
|
+
inputFields: [
|
|
284
|
+
{
|
|
285
|
+
kind: "workspace",
|
|
286
|
+
name: "workspace",
|
|
287
|
+
description: "Workspace to open",
|
|
288
|
+
position: 0,
|
|
289
|
+
required: true,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
kind: "boolean",
|
|
293
|
+
name: "rebuild",
|
|
294
|
+
description: "Rebuild before opening",
|
|
295
|
+
required: false,
|
|
296
|
+
defaultValue: false,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
workflow: "test",
|
|
302
|
+
id: "fork",
|
|
303
|
+
source: "config",
|
|
304
|
+
createsWorkspace: true,
|
|
305
|
+
inputFields: [],
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
} as any);
|
|
309
|
+
|
|
310
|
+
const operation = manifest.operations.find((item) => item.id === "open");
|
|
311
|
+
const forkOperation = manifest.operations.find((item) => item.id === "fork");
|
|
312
|
+
const sshOperation = manifest.operations.find((item) => item.id === "ssh");
|
|
313
|
+
const inputSchema = operation?.inputSchema as any;
|
|
314
|
+
const sshInputSchema = sshOperation?.inputSchema as any;
|
|
315
|
+
|
|
316
|
+
expect(operation?.source).toBe("config");
|
|
317
|
+
expect(operation?.kind).toBe("workspace-action");
|
|
318
|
+
expect(operation?.requiredHostMethods).toEqual([
|
|
319
|
+
{ id: "host.command.run", modes: ["interactive"] },
|
|
320
|
+
]);
|
|
321
|
+
expect(operation?.requiredHostCapabilities).toEqual([
|
|
322
|
+
{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" },
|
|
323
|
+
]);
|
|
324
|
+
expect(manifest.hostCapabilities.optional).toEqual([
|
|
325
|
+
{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" },
|
|
326
|
+
]);
|
|
327
|
+
expect(operation?.cli?.positionals).toEqual([{ name: "workspace", index: 0 }]);
|
|
328
|
+
expect(operation?.cli?.options).toEqual([
|
|
329
|
+
{ name: "rebuild", flag: "--rebuild", required: false, type: "boolean" },
|
|
330
|
+
]);
|
|
331
|
+
expect(inputSchema.required).toEqual(["workspace"]);
|
|
332
|
+
expect(inputSchema.properties.workspace["x-rigkit-input"]).toEqual({
|
|
333
|
+
kind: "workspace",
|
|
334
|
+
workflow: "test",
|
|
335
|
+
resolve: "data",
|
|
336
|
+
});
|
|
337
|
+
expect(inputSchema.properties.rebuild).toMatchObject({
|
|
338
|
+
type: "boolean",
|
|
339
|
+
default: false,
|
|
340
|
+
description: "Rebuild before opening",
|
|
341
|
+
});
|
|
342
|
+
expect(forkOperation?.source).toBe("config");
|
|
343
|
+
expect(forkOperation?.createsWorkspace).toBe(true);
|
|
344
|
+
expect(manifest.operations.some((item) => item.id === "create")).toBe(false);
|
|
345
|
+
expect(sshOperation?.cli?.options?.find((item) => item.name === "print")).toEqual({
|
|
346
|
+
name: "print",
|
|
347
|
+
flag: "--print",
|
|
348
|
+
type: "boolean",
|
|
349
|
+
});
|
|
350
|
+
expect(sshInputSchema.properties.print).toEqual({ type: "boolean", default: false });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("rejects config operation ids reserved by host commands", async () => {
|
|
354
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-runtime-reserved-operation-"));
|
|
355
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
356
|
+
writeFileSync(
|
|
357
|
+
configPath,
|
|
358
|
+
`
|
|
359
|
+
import { defineConfig, sequence } from "${import.meta.dir}/../../../engine/src/index.ts";
|
|
360
|
+
|
|
361
|
+
const root = sequence("reserved-test").operation("completion", {
|
|
362
|
+
run: async () => ({ ok: true }),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
export default defineConfig({
|
|
366
|
+
providers: {},
|
|
367
|
+
workflows: { root },
|
|
368
|
+
});
|
|
369
|
+
`,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await expect(loadEngine({ projectDir, configPath }))
|
|
374
|
+
.rejects.toThrow("reserved by the Rigkit host");
|
|
375
|
+
} finally {
|
|
376
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("negotiates run sessions with hello acknowledgements and heartbeats", async () => {
|
|
381
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-runtime-session-"));
|
|
382
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
383
|
+
writeFileSync(
|
|
384
|
+
configPath,
|
|
385
|
+
`
|
|
386
|
+
import { defineConfig, sequence } from "${import.meta.dir}/../../../engine/src/index.ts";
|
|
387
|
+
|
|
388
|
+
const root = sequence("session-test").step("noop", async () => ({ ok: true }));
|
|
389
|
+
|
|
390
|
+
export default defineConfig({
|
|
391
|
+
providers: {},
|
|
392
|
+
workflows: { root },
|
|
393
|
+
});
|
|
394
|
+
`,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const server = await serveRuntime({
|
|
398
|
+
projectId: "project-session-test",
|
|
399
|
+
projectDir,
|
|
400
|
+
configPath,
|
|
401
|
+
statePath: join(projectDir, "state.sqlite"),
|
|
402
|
+
handlePath: join(projectDir, "runtime.json"),
|
|
403
|
+
tokenPath: join(projectDir, "runtime.token"),
|
|
404
|
+
token: "test-token",
|
|
405
|
+
idleMs: 60_000,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const workflows = await fetch(new URL("/workflows", server.url), {
|
|
410
|
+
headers: { authorization: `Bearer ${server.token}` },
|
|
411
|
+
}).then((response) => response.json() as Promise<{ workflows: Array<{ name: string }> }>);
|
|
412
|
+
expect(workflows.workflows.map((workflow) => workflow.name)).toEqual(["session-test"]);
|
|
413
|
+
|
|
414
|
+
const started = await fetch(new URL("/runs", server.url), {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: {
|
|
417
|
+
authorization: `Bearer ${server.token}`,
|
|
418
|
+
"content-type": "application/json",
|
|
419
|
+
},
|
|
420
|
+
body: JSON.stringify({ operation: "plan", input: {} }),
|
|
421
|
+
}).then((response) => response.json() as Promise<{ sessionUrl: string }>);
|
|
422
|
+
|
|
423
|
+
const messages = await collectSessionMessages(new URL(started.sessionUrl, server.url), server.token);
|
|
424
|
+
const ack = messages.find((message) => message.type === "hello.ack");
|
|
425
|
+
|
|
426
|
+
expect(ack?.operation).toEqual({
|
|
427
|
+
id: "plan",
|
|
428
|
+
requiredHostCapabilities: [],
|
|
429
|
+
requiredHostMethods: [],
|
|
430
|
+
});
|
|
431
|
+
expect(messages.some((message) => message.type === "heartbeat.ack")).toBe(true);
|
|
432
|
+
expect(messages.some((message) => message.type === "run.completed")).toBe(true);
|
|
433
|
+
} finally {
|
|
434
|
+
server.stop();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("exposes persisted workspace payload as workspace data", async () => {
|
|
439
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-runtime-workspace-data-"));
|
|
440
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
441
|
+
writeFileSync(
|
|
442
|
+
configPath,
|
|
443
|
+
`
|
|
444
|
+
import { defineConfig, sequence } from "${import.meta.dir}/../../../engine/src/index.ts";
|
|
445
|
+
|
|
446
|
+
const root = sequence("workspace-data")
|
|
447
|
+
.step("prepare", async () => ({ repoPath: "/workspace/repo" }))
|
|
448
|
+
.create(async ({ ctx, name }) => ({
|
|
449
|
+
name,
|
|
450
|
+
resourceId: "resource-" + name,
|
|
451
|
+
repoPath: ctx.repoPath,
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
export default defineConfig({
|
|
455
|
+
providers: {},
|
|
456
|
+
workflows: { root },
|
|
457
|
+
});
|
|
458
|
+
`,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const server = await serveRuntime({
|
|
462
|
+
projectId: "project-workspace-data-test",
|
|
463
|
+
projectDir,
|
|
464
|
+
configPath,
|
|
465
|
+
statePath: join(projectDir, "state.sqlite"),
|
|
466
|
+
handlePath: join(projectDir, "runtime.json"),
|
|
467
|
+
tokenPath: join(projectDir, "runtime.token"),
|
|
468
|
+
token: "test-token",
|
|
469
|
+
idleMs: 60_000,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const started = await fetch(new URL("/runs", server.url), {
|
|
474
|
+
method: "POST",
|
|
475
|
+
headers: {
|
|
476
|
+
authorization: `Bearer ${server.token}`,
|
|
477
|
+
"content-type": "application/json",
|
|
478
|
+
},
|
|
479
|
+
body: JSON.stringify({ operation: "create", input: { name: "demo" } }),
|
|
480
|
+
}).then((response) => response.json() as Promise<{ sessionUrl: string }>);
|
|
481
|
+
|
|
482
|
+
await collectSessionMessages(new URL(started.sessionUrl, server.url), server.token);
|
|
483
|
+
|
|
484
|
+
const { workspaces } = await fetch(new URL("/workspaces", server.url), {
|
|
485
|
+
headers: { authorization: `Bearer ${server.token}` },
|
|
486
|
+
}).then((response) => response.json() as Promise<{ workspaces: Array<{ data: Record<string, unknown>; metadata: Record<string, unknown> }> }>);
|
|
487
|
+
|
|
488
|
+
expect(workspaces[0]?.data).toEqual({
|
|
489
|
+
name: "demo",
|
|
490
|
+
resourceId: "resource-demo",
|
|
491
|
+
repoPath: "/workspace/repo",
|
|
492
|
+
});
|
|
493
|
+
expect(workspaces[0]?.metadata).toEqual(workspaces[0]?.data);
|
|
494
|
+
} finally {
|
|
495
|
+
server.stop();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("reports typed operation validation failures on run events", async () => {
|
|
500
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-runtime-validation-"));
|
|
501
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
502
|
+
writeFileSync(
|
|
503
|
+
configPath,
|
|
504
|
+
`
|
|
505
|
+
import { defineConfig, sequence } from "${import.meta.dir}/../../../engine/src/index.ts";
|
|
506
|
+
|
|
507
|
+
const root = sequence("validation")
|
|
508
|
+
.step("prepare", async () => ({ ok: true }))
|
|
509
|
+
.create(async ({ name }) => ({ name, resourceId: "resource-" + name }));
|
|
510
|
+
|
|
511
|
+
export default defineConfig({
|
|
512
|
+
providers: {},
|
|
513
|
+
workflows: { root },
|
|
514
|
+
});
|
|
515
|
+
`,
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const server = await serveRuntime({
|
|
519
|
+
projectId: "project-validation-test",
|
|
520
|
+
projectDir,
|
|
521
|
+
configPath,
|
|
522
|
+
statePath: join(projectDir, "state.sqlite"),
|
|
523
|
+
handlePath: join(projectDir, "runtime.json"),
|
|
524
|
+
tokenPath: join(projectDir, "runtime.token"),
|
|
525
|
+
token: "test-token",
|
|
526
|
+
idleMs: 60_000,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const started = await fetch(new URL("/runs", server.url), {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: {
|
|
533
|
+
authorization: `Bearer ${server.token}`,
|
|
534
|
+
"content-type": "application/json",
|
|
535
|
+
},
|
|
536
|
+
body: JSON.stringify({ operation: "create", input: {} }),
|
|
537
|
+
}).then((response) => response.json() as Promise<{ sessionUrl: string }>);
|
|
538
|
+
|
|
539
|
+
const messages = await collectSessionMessages(
|
|
540
|
+
new URL(started.sessionUrl, server.url),
|
|
541
|
+
server.token,
|
|
542
|
+
{ done: (items) => items.some((item) => item.type === "run.failed") },
|
|
543
|
+
);
|
|
544
|
+
const failed = messages.find((message) => message.type === "run.failed");
|
|
545
|
+
|
|
546
|
+
expect(failed?.error?.code).toBe("OPERATION_VALIDATION_FAILED");
|
|
547
|
+
expect(failed?.error?.message).toContain("Invalid input for operation create");
|
|
548
|
+
expect(new RuntimeOperationValidationError({ operation: "create", cause: "missing name" }).code)
|
|
549
|
+
.toBe("OPERATION_VALIDATION_FAILED");
|
|
550
|
+
} finally {
|
|
551
|
+
server.stop();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("fails run sessions when host hello lacks required methods or capabilities", async () => {
|
|
556
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-required-host-", `
|
|
557
|
+
const root = sequence("required-host").operation("needs-host", {
|
|
558
|
+
requiredHostMethods: [{ id: "host.command.run", modes: ["capture"] }],
|
|
559
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
560
|
+
run: async () => await new Promise(() => {}),
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
export default defineConfig({
|
|
564
|
+
providers: {},
|
|
565
|
+
workflows: { root },
|
|
566
|
+
});
|
|
567
|
+
`);
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const started = await startRun(server, "needs-host");
|
|
571
|
+
const messages = await collectSessionMessages(
|
|
572
|
+
new URL(started.sessionUrl, server.url),
|
|
573
|
+
server.token,
|
|
574
|
+
{
|
|
575
|
+
done: (items) => items.some((item) => item.type === "run.failed"),
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
const failed = messages.find((message) => message.type === "run.failed");
|
|
579
|
+
const message = String(failed?.error?.message ?? "");
|
|
580
|
+
|
|
581
|
+
expect(failed?.error?.code).toBe("HOST_REQUEST_FAILED");
|
|
582
|
+
expect(message).toContain("host method host.command.run:capture");
|
|
583
|
+
expect(message).toContain("host capability cmux.open@sha256:cmux-open-schema");
|
|
584
|
+
expect(messages.some((item) => item.type === "hello.ack")).toBe(false);
|
|
585
|
+
} finally {
|
|
586
|
+
server.stop();
|
|
587
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("bridges typed host capability requests over run sessions", async () => {
|
|
592
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-capability-", `
|
|
593
|
+
const root = sequence("capability-test").operation("open", {
|
|
594
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
595
|
+
run: async ({ local }) => await local.requestCapability("cmux.open", { name: "demo" }),
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
export default defineConfig({
|
|
599
|
+
providers: {},
|
|
600
|
+
workflows: { root },
|
|
601
|
+
});
|
|
602
|
+
`);
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const started = await startRun(server, "open");
|
|
606
|
+
const messages = await collectSessionMessages(
|
|
607
|
+
new URL(started.sessionUrl, server.url),
|
|
608
|
+
server.token,
|
|
609
|
+
{
|
|
610
|
+
hello: helloWithCapability(),
|
|
611
|
+
onMessage(message, ws) {
|
|
612
|
+
if (message.type !== "host.capability.request") return;
|
|
613
|
+
ws.send(JSON.stringify({
|
|
614
|
+
type: "response",
|
|
615
|
+
id: message.id,
|
|
616
|
+
result: { sessionId: "cmux-session-1" },
|
|
617
|
+
}));
|
|
618
|
+
},
|
|
619
|
+
done: (items) => items.some((item) => item.type === "run.completed"),
|
|
620
|
+
},
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const request = messages.find((message) => message.type === "host.capability.request");
|
|
624
|
+
const completed = messages.find((message) => message.type === "run.completed");
|
|
625
|
+
expect(request).toMatchObject({
|
|
626
|
+
type: "host.capability.request",
|
|
627
|
+
capability: "cmux.open",
|
|
628
|
+
params: { name: "demo" },
|
|
629
|
+
});
|
|
630
|
+
expect(completed?.result).toEqual({ sessionId: "cmux-session-1" });
|
|
631
|
+
} finally {
|
|
632
|
+
server.stop();
|
|
633
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("resolves host capability resource lifetimes from session close reports", async () => {
|
|
638
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-capability-close-", `
|
|
639
|
+
const root = sequence("capability-close-test").operation("open", {
|
|
640
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
641
|
+
run: async ({ local }) => {
|
|
642
|
+
if (!local.requestCapabilitySession) throw new Error("requestCapabilitySession unavailable");
|
|
643
|
+
const session = await local.requestCapabilitySession("cmux.open", { name: "demo" });
|
|
644
|
+
await session.closed;
|
|
645
|
+
return session.result;
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
export default defineConfig({
|
|
650
|
+
providers: {},
|
|
651
|
+
workflows: { root },
|
|
652
|
+
});
|
|
653
|
+
`);
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const started = await startRun(server, "open");
|
|
657
|
+
const messages = await collectSessionMessages(
|
|
658
|
+
new URL(started.sessionUrl, server.url),
|
|
659
|
+
server.token,
|
|
660
|
+
{
|
|
661
|
+
hello: helloWithCapability(),
|
|
662
|
+
onMessage(message, ws) {
|
|
663
|
+
if (message.type !== "host.capability.request") return;
|
|
664
|
+
ws.send(JSON.stringify({
|
|
665
|
+
type: "response",
|
|
666
|
+
id: message.id,
|
|
667
|
+
result: { sessionId: "cmux-session-1" },
|
|
668
|
+
}));
|
|
669
|
+
setTimeout(() => ws.send(JSON.stringify({
|
|
670
|
+
type: "host.capability.closed",
|
|
671
|
+
id: message.id,
|
|
672
|
+
})), 10);
|
|
673
|
+
},
|
|
674
|
+
done: (items) => items.some((item) => item.type === "run.completed"),
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
const completed = messages.find((message) => message.type === "run.completed");
|
|
679
|
+
expect(completed?.result).toEqual({ sessionId: "cmux-session-1" });
|
|
680
|
+
} finally {
|
|
681
|
+
server.stop();
|
|
682
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("keeps host-owned capability runs attached until the host cancels", async () => {
|
|
687
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-capability-attached-", `
|
|
688
|
+
const root = sequence("capability-attached-test").operation("open", {
|
|
689
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
690
|
+
run: async ({ local }) => {
|
|
691
|
+
await local.requestCapability("cmux.open", { name: "demo" });
|
|
692
|
+
await new Promise(() => {});
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
export default defineConfig({
|
|
697
|
+
providers: {},
|
|
698
|
+
workflows: { root },
|
|
699
|
+
});
|
|
700
|
+
`);
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const started = await startRun(server, "open");
|
|
704
|
+
const messages = await collectSessionMessages(
|
|
705
|
+
new URL(started.sessionUrl, server.url),
|
|
706
|
+
server.token,
|
|
707
|
+
{
|
|
708
|
+
hello: helloWithCapability(),
|
|
709
|
+
onMessage(message, ws) {
|
|
710
|
+
if (message.type !== "host.capability.request") return;
|
|
711
|
+
ws.send(JSON.stringify({
|
|
712
|
+
type: "response",
|
|
713
|
+
id: message.id,
|
|
714
|
+
result: { sessionId: "cmux-session-1" },
|
|
715
|
+
}));
|
|
716
|
+
setTimeout(() => ws.send(JSON.stringify({ type: "run.cancel" })), 0);
|
|
717
|
+
},
|
|
718
|
+
done: (items) => items.some((item) => item.type === "run.failed"),
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const request = messages.find((message) => message.type === "host.capability.request");
|
|
723
|
+
const failed = messages.find((message) => message.type === "run.failed");
|
|
724
|
+
expect(request?.capability).toBe("cmux.open");
|
|
725
|
+
expect(messages.some((message) => message.type === "run.completed")).toBe(false);
|
|
726
|
+
expect(failed?.error?.code).toBe("RUN_CANCELLED");
|
|
727
|
+
} finally {
|
|
728
|
+
server.stop();
|
|
729
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("turns host response errors into typed host request failures", async () => {
|
|
734
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-capability-error-", `
|
|
735
|
+
const root = sequence("capability-error-test").operation("cmux-open", {
|
|
736
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
737
|
+
run: async ({ local }) => await local.requestCapability("cmux.open", { name: "demo" }),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
export default defineConfig({
|
|
741
|
+
providers: {},
|
|
742
|
+
workflows: { root },
|
|
743
|
+
});
|
|
744
|
+
`);
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
const started = await startRun(server, "cmux-open");
|
|
748
|
+
const messages = await collectSessionMessages(
|
|
749
|
+
new URL(started.sessionUrl, server.url),
|
|
750
|
+
server.token,
|
|
751
|
+
{
|
|
752
|
+
hello: helloWithCapability(),
|
|
753
|
+
onMessage(message, ws) {
|
|
754
|
+
if (message.type !== "host.capability.request") return;
|
|
755
|
+
ws.send(JSON.stringify({
|
|
756
|
+
type: "response",
|
|
757
|
+
id: message.id,
|
|
758
|
+
error: { code: "HOST_CAPABILITY_FAILED", message: "cmux is not running" },
|
|
759
|
+
}));
|
|
760
|
+
},
|
|
761
|
+
done: (items) => items.some((item) => item.type === "run.failed"),
|
|
762
|
+
},
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const failed = messages.find((message) => message.type === "run.failed");
|
|
766
|
+
expect(failed?.error?.code).toBe("HOST_REQUEST_FAILED");
|
|
767
|
+
expect(failed?.error?.message).toContain("cmux is not running");
|
|
768
|
+
} finally {
|
|
769
|
+
server.stop();
|
|
770
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("turns run.cancel session messages into run failures", async () => {
|
|
775
|
+
const { server, projectDir } = await serveRuntimeFixture("rigkit-runtime-cancel-", `
|
|
776
|
+
const root = sequence("cancel-test").operation("long-running", {
|
|
777
|
+
run: async () => await new Promise(() => {}),
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
export default defineConfig({
|
|
781
|
+
providers: {},
|
|
782
|
+
workflows: { root },
|
|
783
|
+
});
|
|
784
|
+
`);
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
const started = await startRun(server, "long-running");
|
|
788
|
+
const messages = await collectSessionMessages(
|
|
789
|
+
new URL(started.sessionUrl, server.url),
|
|
790
|
+
server.token,
|
|
791
|
+
{
|
|
792
|
+
afterOpen: (ws) => {
|
|
793
|
+
setTimeout(() => ws.send(JSON.stringify({ type: "run.cancel" })), 0);
|
|
794
|
+
},
|
|
795
|
+
done: (items) => items.some((item) => item.type === "run.failed"),
|
|
796
|
+
},
|
|
797
|
+
);
|
|
798
|
+
const failed = messages.find((message) => message.type === "run.failed");
|
|
799
|
+
|
|
800
|
+
expect(failed?.error?.code).toBe("RUN_CANCELLED");
|
|
801
|
+
expect(failed?.error?.message).toBe("Run cancelled by host");
|
|
802
|
+
} finally {
|
|
803
|
+
server.stop();
|
|
804
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
async function serveRuntimeFixture(prefix: string, configBody: string) {
|
|
810
|
+
const projectDir = mkdtempSync(join(tmpdir(), prefix));
|
|
811
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
812
|
+
writeFileSync(
|
|
813
|
+
configPath,
|
|
814
|
+
`
|
|
815
|
+
import { defineConfig, sequence } from "${import.meta.dir}/../../../engine/src/index.ts";
|
|
816
|
+
|
|
817
|
+
${configBody}
|
|
818
|
+
`,
|
|
819
|
+
);
|
|
820
|
+
const server = await serveRuntime({
|
|
821
|
+
projectId: prefix.replace(/[^a-z0-9-]/gi, ""),
|
|
822
|
+
projectDir,
|
|
823
|
+
configPath,
|
|
824
|
+
statePath: join(projectDir, "state.sqlite"),
|
|
825
|
+
handlePath: join(projectDir, "runtime.json"),
|
|
826
|
+
tokenPath: join(projectDir, "runtime.token"),
|
|
827
|
+
token: "test-token",
|
|
828
|
+
idleMs: 60_000,
|
|
829
|
+
});
|
|
830
|
+
return { projectDir, server };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function startRun(
|
|
834
|
+
server: { url: string; token: string },
|
|
835
|
+
operation: string,
|
|
836
|
+
input: unknown = {},
|
|
837
|
+
): Promise<{ runId: string; sessionUrl: string }> {
|
|
838
|
+
const response = await fetch(new URL("/runs", server.url), {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers: {
|
|
841
|
+
authorization: `Bearer ${server.token}`,
|
|
842
|
+
"content-type": "application/json",
|
|
843
|
+
},
|
|
844
|
+
body: JSON.stringify({ operation, input }),
|
|
845
|
+
});
|
|
846
|
+
expect(response.status).toBe(202);
|
|
847
|
+
return await response.json() as { runId: string; sessionUrl: string };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function helloWithCapability(): Record<string, unknown> {
|
|
851
|
+
return {
|
|
852
|
+
type: "hello",
|
|
853
|
+
transportVersion: 1,
|
|
854
|
+
host: { name: "test", version: "0.0.0" },
|
|
855
|
+
hostMethods: [],
|
|
856
|
+
hostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function collectSessionMessages(
|
|
861
|
+
url: URL,
|
|
862
|
+
token: string,
|
|
863
|
+
options: {
|
|
864
|
+
hello?: Record<string, unknown>;
|
|
865
|
+
afterOpen?: (ws: WebSocket) => void;
|
|
866
|
+
onMessage?: (message: any, ws: WebSocket) => void;
|
|
867
|
+
done?: (messages: any[]) => boolean;
|
|
868
|
+
} = {},
|
|
869
|
+
): Promise<any[]> {
|
|
870
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
871
|
+
const messages: any[] = [];
|
|
872
|
+
const ws = new (WebSocket as unknown as {
|
|
873
|
+
new(url: string | URL, options?: Bun.WebSocketOptions): WebSocket;
|
|
874
|
+
})(url, {
|
|
875
|
+
headers: { authorization: `Bearer ${token}` },
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
return await Promise.race([
|
|
879
|
+
new Promise<any[]>((resolve, reject) => {
|
|
880
|
+
ws.addEventListener("open", () => {
|
|
881
|
+
ws.send(JSON.stringify(options.hello ?? {
|
|
882
|
+
type: "hello",
|
|
883
|
+
transportVersion: 1,
|
|
884
|
+
host: { name: "test", version: "0.0.0" },
|
|
885
|
+
hostMethods: [],
|
|
886
|
+
hostCapabilities: [],
|
|
887
|
+
}));
|
|
888
|
+
ws.send(JSON.stringify({ type: "heartbeat" }));
|
|
889
|
+
options.afterOpen?.(ws);
|
|
890
|
+
});
|
|
891
|
+
ws.addEventListener("message", (event) => {
|
|
892
|
+
const message = JSON.parse(String(event.data));
|
|
893
|
+
messages.push(message);
|
|
894
|
+
options.onMessage?.(message, ws);
|
|
895
|
+
const done = options.done ?? ((items: any[]) =>
|
|
896
|
+
items.some((item) => item.type === "hello.ack") &&
|
|
897
|
+
items.some((item) => item.type === "heartbeat.ack") &&
|
|
898
|
+
items.some((item) => item.type === "run.completed")
|
|
899
|
+
);
|
|
900
|
+
if (done(messages)) {
|
|
901
|
+
ws.close();
|
|
902
|
+
resolve(messages);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
ws.addEventListener("error", () => reject(new Error(`WebSocket session failed`)));
|
|
906
|
+
}),
|
|
907
|
+
new Promise<any[]>((_, reject) => {
|
|
908
|
+
setTimeout(() => reject(new Error("Timed out waiting for run session messages")), 5_000);
|
|
909
|
+
}),
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function testContext(): RuntimeContext {
|
|
914
|
+
return {
|
|
915
|
+
projectId: "project-test",
|
|
916
|
+
projectDir: "/tmp/rigkit-project",
|
|
917
|
+
configPath: "/tmp/rigkit-project/rig.config.ts",
|
|
918
|
+
token: "test-token",
|
|
919
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
920
|
+
getExpiresAt: () => "2026-01-01T00:30:00.000Z",
|
|
921
|
+
touch: () => {},
|
|
922
|
+
stop: () => {},
|
|
923
|
+
};
|
|
924
|
+
}
|