@rigkit/runtime-client 0.0.0-canary-20260518T014918-c5bc0c2

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/src/http.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { Effect } from "effect";
2
+ import {
3
+ RuntimeApiVersionError,
4
+ RuntimeAuthError,
5
+ RuntimeConnectionError,
6
+ RuntimeHttpError,
7
+ type RuntimeClientError,
8
+ isRuntimeClientError,
9
+ } from "./errors.ts";
10
+
11
+ export const SUPPORTED_RUNTIME_API_VERSION = 1;
12
+
13
+ export function runtimeStreamEffect(
14
+ baseUrl: string,
15
+ token: string,
16
+ path: string,
17
+ onEvent: (event: unknown) => Promise<void> | void,
18
+ ): Effect.Effect<void, RuntimeClientError> {
19
+ return Effect.tryPromise({
20
+ try: () => runtimeStreamUnsafe(baseUrl, token, path, onEvent),
21
+ catch: (cause) => toRuntimeTransportError(cause, { method: "GET", path }),
22
+ });
23
+ }
24
+
25
+ export function assertSupportedApiVersion(response: Response): void {
26
+ assertSupportedApiVersionHeader(response.headers.get("x-rigkit-api-version"));
27
+ }
28
+
29
+ export function assertSupportedApiVersionHeader(version: string | null | undefined): void {
30
+ if (version !== String(SUPPORTED_RUNTIME_API_VERSION)) {
31
+ throw new RuntimeApiVersionError({
32
+ version: version ?? null,
33
+ supportedVersion: SUPPORTED_RUNTIME_API_VERSION,
34
+ });
35
+ }
36
+ }
37
+
38
+ export function toRuntimeTransportError(
39
+ cause: unknown,
40
+ context: { method: string; path: string },
41
+ ): RuntimeClientError {
42
+ if (isRuntimeClientError(cause)) {
43
+ return cause;
44
+ }
45
+ return new RuntimeConnectionError({
46
+ method: context.method,
47
+ path: context.path,
48
+ cause,
49
+ });
50
+ }
51
+
52
+ async function runtimeStreamUnsafe(
53
+ baseUrl: string,
54
+ token: string,
55
+ path: string,
56
+ onEvent: (event: unknown) => Promise<void> | void,
57
+ ): Promise<void> {
58
+ const response = await fetch(`${baseUrl}${path}`, {
59
+ headers: { authorization: `Bearer ${token}` },
60
+ });
61
+ if (!response.ok || !response.body) {
62
+ throw runtimeHttpError("GET", path, response.status);
63
+ }
64
+ assertSupportedApiVersion(response);
65
+ await readSse(response.body, onEvent);
66
+ }
67
+
68
+ function runtimeHttpError(method: string, path: string, status: number, message?: string): RuntimeHttpError | RuntimeAuthError {
69
+ if (status === 401 || status === 403) {
70
+ return new RuntimeAuthError({ method, path, status, message });
71
+ }
72
+ return new RuntimeHttpError({ method, path, status, message });
73
+ }
74
+
75
+ async function readSse(body: ReadableStream<Uint8Array>, onEvent: (event: unknown) => Promise<void> | void): Promise<void> {
76
+ const reader = body.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buffer = "";
79
+
80
+ for (;;) {
81
+ const { value, done } = await reader.read();
82
+ if (done) break;
83
+ buffer += decoder.decode(value, { stream: true });
84
+ for (;;) {
85
+ const index = buffer.indexOf("\n\n");
86
+ if (index < 0) break;
87
+ const raw = buffer.slice(0, index);
88
+ buffer = buffer.slice(index + 2);
89
+ const data = raw.split(/\r?\n/)
90
+ .filter((line) => line.startsWith("data:"))
91
+ .map((line) => line.slice(5).trimStart())
92
+ .join("\n");
93
+ if (data) await onEvent(JSON.parse(data));
94
+ }
95
+ }
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ export {
2
+ defaultRigkitHome,
3
+ getOrStartRuntime,
4
+ connectRemoteRuntime,
5
+ projectIdFor,
6
+ runtimeFingerprintFor,
7
+ runtimePaths,
8
+ getOrStartRuntimeEffect,
9
+ type GetOrStartRuntimeOptions,
10
+ type RemoteRuntimeOptions,
11
+ type RuntimeClient,
12
+ type RuntimePaths,
13
+ type RuntimeProjectOptions,
14
+ } from "./manager.ts";
15
+ export {
16
+ SUPPORTED_RUNTIME_API_VERSION,
17
+ assertSupportedApiVersion,
18
+ assertSupportedApiVersionHeader,
19
+ } from "./http.ts";
20
+ export {
21
+ createRuntimeHttpClient,
22
+ runtimeHttpClientEffect,
23
+ type RuntimeHttpClient,
24
+ type RuntimeHttpClientOptions,
25
+ } from "./client.ts";
26
+ export {
27
+ RuntimeControlHealthEffectSchema,
28
+ RuntimeControlHostResponseEffectSchema,
29
+ RuntimeControlMetadataEffectSchema,
30
+ RuntimeControlOkResponseEffectSchema,
31
+ RuntimeControlOperationCliEffectSchema,
32
+ RuntimeControlOperationEffectSchema,
33
+ RuntimeControlOperationsManifestEffectSchema,
34
+ RuntimeControlCacheEntryEffectSchema,
35
+ RuntimeControlCacheResponseEffectSchema,
36
+ RuntimeControlCacheClearRequestEffectSchema,
37
+ RuntimeControlCacheClearResponseEffectSchema,
38
+ RuntimeControlProjectInfoEffectSchema,
39
+ RuntimeControlRunEffectSchema,
40
+ RuntimeControlRunOperationRequestEffectSchema,
41
+ RuntimeControlRunStartedEffectSchema,
42
+ RuntimeControlRunsResponseEffectSchema,
43
+ RuntimeControlSnapshotsResponseEffectSchema,
44
+ RuntimeControlWorkflowSummaryEffectSchema,
45
+ RuntimeControlWorkflowsResponseEffectSchema,
46
+ RuntimeControlWorkspaceEffectSchema,
47
+ RuntimeControlWorkspacesResponseEffectSchema,
48
+ runtimeControlApi,
49
+ type RuntimeControlHealth,
50
+ type RuntimeControlHostResponse,
51
+ type RuntimeControlMetadata,
52
+ type RuntimeControlOkResponse,
53
+ type RuntimeControlOperation,
54
+ type RuntimeControlOperationCli,
55
+ type RuntimeControlOperationsManifest,
56
+ type RuntimeControlCacheEntry,
57
+ type RuntimeControlCacheResponse,
58
+ type RuntimeControlCacheClearRequest,
59
+ type RuntimeControlCacheClearResponse,
60
+ type RuntimeControlProjectInfo,
61
+ type RuntimeControlRun,
62
+ type RuntimeControlRunOperationRequest,
63
+ type RuntimeControlRunStarted,
64
+ type RuntimeControlRunsResponse,
65
+ type RuntimeControlSnapshotsResponse,
66
+ type RuntimeControlWorkflowSummary,
67
+ type RuntimeControlWorkflowsResponse,
68
+ type RuntimeControlWorkspace,
69
+ type RuntimeControlWorkspacesResponse,
70
+ } from "./api.ts";
71
+ export {
72
+ runtimeSessionEffect,
73
+ type RuntimeSessionConnection,
74
+ type RuntimeSessionHandlers,
75
+ type RuntimeSessionHello,
76
+ } from "./session.ts";
77
+ export {
78
+ RuntimeApiVersionError,
79
+ RuntimeAuthError,
80
+ RuntimeConnectionError,
81
+ RuntimeHttpError,
82
+ RuntimeProtocolError,
83
+ RuntimeSessionError,
84
+ RuntimeStartupError,
85
+ isRuntimeClientError,
86
+ type RuntimeClientError,
87
+ type RuntimeStartupErrorReason,
88
+ } from "./errors.ts";
89
+ export { RIGKIT_RUNTIME_CLIENT_VERSION } from "./version.ts";
90
+ export {
91
+ RuntimeClientSchemaError,
92
+ RuntimeErrorResponseEffectSchema,
93
+ RuntimeHandleSchema,
94
+ RuntimeHandleEffectSchema,
95
+ RuntimeHealthSchema,
96
+ RuntimeHealthEffectSchema,
97
+ RuntimeMetadataSchema,
98
+ RuntimeMetadataEffectSchema,
99
+ RuntimeReadySchema,
100
+ RuntimeReadyEffectSchema,
101
+ type RuntimeHandle,
102
+ type RuntimeHealth,
103
+ type RuntimeMetadata,
104
+ type RuntimeReady,
105
+ } from "./schemas.ts";
@@ -0,0 +1,347 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ connectRemoteRuntime,
7
+ getOrStartRuntime,
8
+ projectIdFor,
9
+ runtimeFingerprintFor,
10
+ runtimePaths,
11
+ SUPPORTED_RUNTIME_API_VERSION,
12
+ } from "./manager.ts";
13
+ import {
14
+ RuntimeApiVersionError,
15
+ RuntimeAuthError,
16
+ RuntimeProtocolError,
17
+ RuntimeStartupError,
18
+ } from "./errors.ts";
19
+
20
+ describe("runtime manager", () => {
21
+ test("computes stable ids from project and config paths", () => {
22
+ const first = projectIdFor({
23
+ projectDir: "/tmp/project",
24
+ configPath: "/tmp/project/rig.config.ts",
25
+ });
26
+ const second = projectIdFor({
27
+ projectDir: "/tmp/project",
28
+ configPath: "/tmp/project/rig.config.ts",
29
+ });
30
+ const differentConfig = projectIdFor({
31
+ projectDir: "/tmp/project",
32
+ configPath: "/tmp/project/other.config.ts",
33
+ });
34
+ const differentSource = projectIdFor({
35
+ projectDir: "/tmp/project",
36
+ configPath: "/tmp/project/rig.config.ts",
37
+ source: { kind: "github", commitSha: "abc" },
38
+ });
39
+
40
+ expect(first).toBe(second);
41
+ expect(first.startsWith("sha256-")).toBe(true);
42
+ expect(differentConfig).not.toBe(first);
43
+ expect(differentSource).not.toBe(first);
44
+ });
45
+
46
+ test("keeps project ids stable while config fingerprints change", () => {
47
+ const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-client-id-"));
48
+ try {
49
+ const projectDir = join(root, "project");
50
+ const configPath = join(projectDir, "rig.config.ts");
51
+ mkdirSync(projectDir, { recursive: true });
52
+ writeFileSync(configPath, "export default { name: 'one' }\n");
53
+
54
+ const first = projectIdFor({ projectDir, configPath });
55
+ const second = projectIdFor({ projectDir, configPath });
56
+ const firstFingerprint = runtimeFingerprintFor({ projectDir, configPath });
57
+ writeFileSync(configPath, "export default { name: 'two' }\n");
58
+ const changed = projectIdFor({ projectDir, configPath });
59
+ const changedFingerprint = runtimeFingerprintFor({ projectDir, configPath });
60
+
61
+ expect(second).toBe(first);
62
+ expect(changed).toBe(first);
63
+ expect(changedFingerprint).not.toBe(firstFingerprint);
64
+ } finally {
65
+ rmSync(root, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("restarts local runtimes when the runtime fingerprint changes", async () => {
70
+ const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-client-restart-"));
71
+ let first: Awaited<ReturnType<typeof getOrStartRuntime>> | undefined;
72
+ let second: Awaited<ReturnType<typeof getOrStartRuntime>> | undefined;
73
+
74
+ try {
75
+ const projectDir = join(root, "project");
76
+ const rigkitHome = join(root, "home");
77
+ const configPath = join(projectDir, "rig.config.ts");
78
+ mkdirSync(projectDir, { recursive: true });
79
+ writeFileSync(configPath, "export default { name: 'one' }\n");
80
+ writeFakeRuntimeBin(projectDir);
81
+
82
+ first = await getOrStartRuntime({
83
+ projectDir,
84
+ configPath,
85
+ rigkitHome,
86
+ idleMs: 60_000,
87
+ });
88
+ const firstHealth = await first.control.health();
89
+
90
+ writeFileSync(configPath, "export default { name: 'two' }\n");
91
+ second = await getOrStartRuntime({
92
+ projectDir,
93
+ configPath,
94
+ rigkitHome,
95
+ idleMs: 60_000,
96
+ });
97
+ const secondHealth = await second.control.health();
98
+
99
+ expect(second.handle.projectId).toBe(first.handle.projectId);
100
+ expect(second.paths.handlePath).toBe(first.paths.handlePath);
101
+ expect(second.handle.runtimeFingerprint).not.toBe(first.handle.runtimeFingerprint);
102
+ expect(second.handle.pid).not.toBe(first.handle.pid);
103
+ expect(secondHealth.runtimeFingerprint).toBe(second.handle.runtimeFingerprint);
104
+ expect(firstHealth.projectId).toBe(secondHealth.projectId);
105
+ } finally {
106
+ await second?.control.shutdown().catch(() => {});
107
+ await first?.control.shutdown().catch(() => {});
108
+ rmSync(root, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test("derives handle, token, and lock paths from rigkit home", () => {
113
+ const paths = runtimePaths("sha256-test", "/tmp/rigkit-home");
114
+
115
+ expect(paths.root).toBe(join("/tmp/rigkit-home", "runtimes"));
116
+ expect(paths.handlePath).toBe(join("/tmp/rigkit-home", "runtimes", "sha256-test.json"));
117
+ expect(paths.tokenPath).toBe(join("/tmp/rigkit-home", "runtimes", "sha256-test.token"));
118
+ expect(paths.lockPath).toBe(join("/tmp/rigkit-home", "runtimes", "sha256-test.lock"));
119
+ });
120
+
121
+ test("remote run event clients reject unsupported runtime API versions", async () => {
122
+ let path = "";
123
+ const server = Bun.serve({
124
+ hostname: "127.0.0.1",
125
+ port: 0,
126
+ fetch: (request) => {
127
+ path = new URL(request.url).pathname;
128
+ return new Response("", {
129
+ headers: { "x-rigkit-api-version": String(SUPPORTED_RUNTIME_API_VERSION + 1) },
130
+ });
131
+ },
132
+ });
133
+
134
+ try {
135
+ const runtime = connectRemoteRuntime({
136
+ url: `http://127.0.0.1:${server.port}`,
137
+ token: "test-token",
138
+ });
139
+
140
+ await expect(runtime.runEvents("run/id", () => {})).rejects.toThrow("Unsupported Rigkit runtime API version");
141
+ await expect(runtime.runEvents("run/id", () => {})).rejects.toBeInstanceOf(RuntimeApiVersionError);
142
+ expect(path).toBe("/runs/run%2Fid/events");
143
+ } finally {
144
+ server.stop(true);
145
+ }
146
+ });
147
+
148
+ test("remote control clients use the typed runtime API", async () => {
149
+ const metadata = {
150
+ apiVersion: SUPPORTED_RUNTIME_API_VERSION,
151
+ engineVersion: "engine-test",
152
+ runtimeVersion: "runtime-test",
153
+ protocolHash: "sha256:test",
154
+ };
155
+ let authorization = "";
156
+ let path = "";
157
+ const server = Bun.serve({
158
+ hostname: "127.0.0.1",
159
+ port: 0,
160
+ fetch: (request) => {
161
+ authorization = request.headers.get("authorization") ?? "";
162
+ path = new URL(request.url).pathname;
163
+ return Response.json(metadata, {
164
+ headers: { "x-rigkit-api-version": String(SUPPORTED_RUNTIME_API_VERSION) },
165
+ });
166
+ },
167
+ });
168
+
169
+ try {
170
+ const runtime = connectRemoteRuntime({
171
+ url: `http://127.0.0.1:${server.port}`,
172
+ token: "test-token",
173
+ });
174
+
175
+ await expect(runtime.control.runtime()).resolves.toEqual(metadata);
176
+ expect(path).toBe("/runtime");
177
+ expect(authorization).toBe("Bearer test-token");
178
+ } finally {
179
+ server.stop(true);
180
+ }
181
+ });
182
+
183
+ test("remote control clients reject unsupported runtime API versions", async () => {
184
+ const server = Bun.serve({
185
+ hostname: "127.0.0.1",
186
+ port: 0,
187
+ fetch: () => Response.json(
188
+ {
189
+ apiVersion: SUPPORTED_RUNTIME_API_VERSION + 1,
190
+ engineVersion: "engine-test",
191
+ runtimeVersion: "runtime-test",
192
+ protocolHash: "sha256:test",
193
+ },
194
+ { headers: { "x-rigkit-api-version": String(SUPPORTED_RUNTIME_API_VERSION + 1) } },
195
+ ),
196
+ });
197
+
198
+ try {
199
+ const runtime = connectRemoteRuntime({
200
+ url: `http://127.0.0.1:${server.port}`,
201
+ token: "test-token",
202
+ });
203
+
204
+ await expect(runtime.control.runtime()).rejects.toThrow("Unsupported Rigkit runtime API version");
205
+ await expect(runtime.control.runtime()).rejects.toBeInstanceOf(RuntimeApiVersionError);
206
+ } finally {
207
+ server.stop(true);
208
+ }
209
+ });
210
+
211
+ test("remote clients expose typed auth failures", async () => {
212
+ const server = Bun.serve({
213
+ hostname: "127.0.0.1",
214
+ port: 0,
215
+ fetch: () => Response.json(
216
+ { error: { message: "bad token" } },
217
+ {
218
+ status: 401,
219
+ headers: { "x-rigkit-api-version": String(SUPPORTED_RUNTIME_API_VERSION) },
220
+ },
221
+ ),
222
+ });
223
+
224
+ try {
225
+ const runtime = connectRemoteRuntime({
226
+ url: `http://127.0.0.1:${server.port}`,
227
+ token: "test-token",
228
+ });
229
+
230
+ await expect(runtime.control.runtime()).rejects.toThrow("bad token");
231
+ await expect(runtime.control.runtime()).rejects.toBeInstanceOf(RuntimeAuthError);
232
+ } finally {
233
+ server.stop(true);
234
+ }
235
+ });
236
+
237
+ test("remote clients expose typed protocol failures", async () => {
238
+ const server = Bun.serve({
239
+ hostname: "127.0.0.1",
240
+ port: 0,
241
+ fetch: () => new Response("{", {
242
+ headers: { "x-rigkit-api-version": String(SUPPORTED_RUNTIME_API_VERSION) },
243
+ }),
244
+ });
245
+
246
+ try {
247
+ const runtime = connectRemoteRuntime({
248
+ url: `http://127.0.0.1:${server.port}`,
249
+ token: "test-token",
250
+ });
251
+
252
+ await expect(runtime.control.runtime()).rejects.toBeInstanceOf(RuntimeProtocolError);
253
+ } finally {
254
+ server.stop(true);
255
+ }
256
+ });
257
+
258
+ test("local manager exposes typed missing runtime failures", async () => {
259
+ const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-client-"));
260
+ try {
261
+ const projectDir = join(root, "project");
262
+ const rigkitHome = join(root, "home");
263
+
264
+ await expect(getOrStartRuntime({
265
+ projectDir,
266
+ configPath: join(projectDir, "rig.config.ts"),
267
+ rigkitHome,
268
+ })).rejects.toBeInstanceOf(RuntimeStartupError);
269
+ await expect(getOrStartRuntime({
270
+ projectDir,
271
+ configPath: join(projectDir, "rig.config.ts"),
272
+ rigkitHome,
273
+ })).rejects.toMatchObject({ reason: "missing-runtime" });
274
+ } finally {
275
+ rmSync(root, { recursive: true, force: true });
276
+ }
277
+ });
278
+ });
279
+
280
+ function writeFakeRuntimeBin(projectDir: string): void {
281
+ const binDir = join(projectDir, "node_modules", ".bin");
282
+ mkdirSync(binDir, { recursive: true });
283
+ const binPath = join(binDir, process.platform === "win32" ? "rigkit-project-runtime.cmd" : "rigkit-project-runtime");
284
+ writeFileSync(
285
+ binPath,
286
+ `#!/usr/bin/env bun
287
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
288
+ import { dirname, resolve } from "node:path";
289
+
290
+ const args = process.argv.slice(2);
291
+ const options = {};
292
+ for (let i = 1; i < args.length; i += 2) {
293
+ options[args[i].replace(/^--/, "")] = args[i + 1];
294
+ }
295
+
296
+ const token = readFileSync(options.token, "utf8").trim();
297
+ let server;
298
+ server = Bun.serve({
299
+ hostname: "127.0.0.1",
300
+ port: 0,
301
+ fetch(request) {
302
+ const url = new URL(request.url);
303
+ if (request.headers.get("authorization") !== \`Bearer \${token}\`) {
304
+ return Response.json({ error: { message: "Unauthorized" } }, { status: 401 });
305
+ }
306
+ if (url.pathname === "/health") {
307
+ return Response.json({
308
+ ok: true,
309
+ projectId: options["project-id"],
310
+ runtimeFingerprint: options["runtime-fingerprint"],
311
+ projectDir: resolve(options["project-dir"]),
312
+ configPath: resolve(options.config),
313
+ statePath: options.state ? resolve(options.state) : undefined,
314
+ engineVersion: "engine-test",
315
+ runtimeVersion: "runtime-test",
316
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
317
+ }, { headers: { "x-rigkit-api-version": "${SUPPORTED_RUNTIME_API_VERSION}" } });
318
+ }
319
+ if (url.pathname === "/shutdown") {
320
+ setTimeout(() => {
321
+ server.stop(true);
322
+ process.exit(0);
323
+ }, 0);
324
+ return Response.json({ ok: true }, { headers: { "x-rigkit-api-version": "${SUPPORTED_RUNTIME_API_VERSION}" } });
325
+ }
326
+ return Response.json({ error: { message: "Not found" } }, { status: 404 });
327
+ },
328
+ });
329
+
330
+ const handle = {
331
+ projectId: options["project-id"],
332
+ runtimeFingerprint: options["runtime-fingerprint"],
333
+ projectDir: resolve(options["project-dir"]),
334
+ configPath: resolve(options.config),
335
+ statePath: options.state ? resolve(options.state) : undefined,
336
+ pid: process.pid,
337
+ url: \`http://127.0.0.1:\${server.port}\`,
338
+ tokenPath: resolve(options.token),
339
+ };
340
+ mkdirSync(dirname(options.handle), { recursive: true });
341
+ writeFileSync(options.handle, JSON.stringify(handle));
342
+ console.log(JSON.stringify({ type: "ready", url: handle.url, token }));
343
+ await new Promise(() => {});
344
+ `,
345
+ );
346
+ chmodSync(binPath, 0o755);
347
+ }