@reasonlayer/sdk 0.0.1-rc.3

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.
Files changed (101) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +35 -0
  3. package/bin/rl.mjs +75 -0
  4. package/dist/builder.d.ts +38 -0
  5. package/dist/builder.js +41 -0
  6. package/dist/bundler/index.d.ts +32 -0
  7. package/dist/bundler/index.js +143 -0
  8. package/dist/bundler/node.d.ts +10 -0
  9. package/dist/bundler/node.js +55 -0
  10. package/dist/cli/args.d.ts +9 -0
  11. package/dist/cli/args.js +71 -0
  12. package/dist/cli/config.d.ts +38 -0
  13. package/dist/cli/config.js +94 -0
  14. package/dist/cli/convex.d.ts +9 -0
  15. package/dist/cli/convex.js +25 -0
  16. package/dist/cli/format.d.ts +11 -0
  17. package/dist/cli/format.js +140 -0
  18. package/dist/cli/index.d.ts +1 -0
  19. package/dist/cli/index.js +721 -0
  20. package/dist/cli/keychain.d.ts +21 -0
  21. package/dist/cli/keychain.js +100 -0
  22. package/dist/cli/operations.d.ts +106 -0
  23. package/dist/cli/operations.js +623 -0
  24. package/dist/cli/sdk-package.d.ts +18 -0
  25. package/dist/cli/sdk-package.js +45 -0
  26. package/dist/cli/storage-upload.d.ts +9 -0
  27. package/dist/cli/storage-upload.js +21 -0
  28. package/dist/cli/watcher-compile.d.ts +1 -0
  29. package/dist/cli/watcher-compile.js +20 -0
  30. package/dist/cli/watcher-process.d.ts +1 -0
  31. package/dist/cli/watcher-process.js +312 -0
  32. package/dist/cli/workspace-files.d.ts +12 -0
  33. package/dist/cli/workspace-files.js +91 -0
  34. package/dist/cli/workspace-local.d.ts +27 -0
  35. package/dist/cli/workspace-local.js +310 -0
  36. package/dist/cli/workspace-session.d.ts +13 -0
  37. package/dist/cli/workspace-session.js +12 -0
  38. package/dist/compile.d.ts +27 -0
  39. package/dist/compile.js +834 -0
  40. package/dist/index.d.ts +10 -0
  41. package/dist/index.js +5 -0
  42. package/dist/schedule-validation.d.ts +1 -0
  43. package/dist/schedule-validation.js +16 -0
  44. package/dist/selector-schema.d.ts +26 -0
  45. package/dist/selector-schema.js +780 -0
  46. package/dist/selectors.d.ts +25 -0
  47. package/dist/selectors.js +99 -0
  48. package/dist/testkit.d.ts +32 -0
  49. package/dist/testkit.js +161 -0
  50. package/dist/types.d.ts +184 -0
  51. package/dist/types.js +110 -0
  52. package/node_modules/@reasonlayer/integrations/dist/catalog.d.ts +4937 -0
  53. package/node_modules/@reasonlayer/integrations/dist/catalog.js +20 -0
  54. package/node_modules/@reasonlayer/integrations/dist/generated/slack.d.ts +3014 -0
  55. package/node_modules/@reasonlayer/integrations/dist/generated/slack.js +3101 -0
  56. package/node_modules/@reasonlayer/integrations/dist/index.d.ts +2 -0
  57. package/node_modules/@reasonlayer/integrations/dist/index.js +1 -0
  58. package/node_modules/@reasonlayer/integrations/dist/internal/definitions.d.ts +44 -0
  59. package/node_modules/@reasonlayer/integrations/dist/internal/definitions.js +52 -0
  60. package/node_modules/@reasonlayer/integrations/dist/internal/transport.d.ts +7 -0
  61. package/node_modules/@reasonlayer/integrations/dist/internal/transport.js +48 -0
  62. package/node_modules/@reasonlayer/integrations/dist/testkit.d.ts +1 -0
  63. package/node_modules/@reasonlayer/integrations/dist/testkit.js +1 -0
  64. package/node_modules/@reasonlayer/integrations/package.json +26 -0
  65. package/node_modules/@reasonlayer/protocol/dist/environments.d.ts +7 -0
  66. package/node_modules/@reasonlayer/protocol/dist/environments.js +7 -0
  67. package/node_modules/@reasonlayer/protocol/dist/events.d.ts +313 -0
  68. package/node_modules/@reasonlayer/protocol/dist/events.js +20 -0
  69. package/node_modules/@reasonlayer/protocol/dist/index.d.ts +18 -0
  70. package/node_modules/@reasonlayer/protocol/dist/index.js +9 -0
  71. package/node_modules/@reasonlayer/protocol/dist/ir.d.ts +252 -0
  72. package/node_modules/@reasonlayer/protocol/dist/ir.js +116 -0
  73. package/node_modules/@reasonlayer/protocol/dist/permissions.d.ts +5 -0
  74. package/node_modules/@reasonlayer/protocol/dist/permissions.js +30 -0
  75. package/node_modules/@reasonlayer/protocol/dist/readmodels.d.ts +617 -0
  76. package/node_modules/@reasonlayer/protocol/dist/readmodels.js +36 -0
  77. package/node_modules/@reasonlayer/protocol/dist/runtime.d.ts +67 -0
  78. package/node_modules/@reasonlayer/protocol/dist/runtime.js +66 -0
  79. package/node_modules/@reasonlayer/protocol/dist/scheduling.d.ts +69 -0
  80. package/node_modules/@reasonlayer/protocol/dist/scheduling.js +126 -0
  81. package/node_modules/@reasonlayer/protocol/dist/selector.d.ts +23 -0
  82. package/node_modules/@reasonlayer/protocol/dist/selector.js +77 -0
  83. package/node_modules/@reasonlayer/protocol/dist/tasks.d.ts +69 -0
  84. package/node_modules/@reasonlayer/protocol/dist/tasks.js +6 -0
  85. package/node_modules/@reasonlayer/protocol/dist/validate.d.ts +6 -0
  86. package/node_modules/@reasonlayer/protocol/dist/validate.js +18 -0
  87. package/node_modules/@reasonlayer/protocol/package.json +21 -0
  88. package/node_modules/@reasonlayer/runner/dist/environment-materializer.d.ts +13 -0
  89. package/node_modules/@reasonlayer/runner/dist/environment-materializer.js +92 -0
  90. package/node_modules/@reasonlayer/runner/dist/flue-transcript.d.ts +17 -0
  91. package/node_modules/@reasonlayer/runner/dist/flue-transcript.js +182 -0
  92. package/node_modules/@reasonlayer/runner/dist/index.d.ts +184 -0
  93. package/node_modules/@reasonlayer/runner/dist/index.js +884 -0
  94. package/node_modules/@reasonlayer/runner/dist/mcp-supervisor.d.ts +15 -0
  95. package/node_modules/@reasonlayer/runner/dist/mcp-supervisor.js +142 -0
  96. package/node_modules/@reasonlayer/runner/dist/sandbox-env.d.ts +36 -0
  97. package/node_modules/@reasonlayer/runner/dist/sandbox-env.js +171 -0
  98. package/node_modules/@reasonlayer/runner/dist/task-child.d.ts +1 -0
  99. package/node_modules/@reasonlayer/runner/dist/task-child.js +72 -0
  100. package/node_modules/@reasonlayer/runner/package.json +21 -0
  101. package/package.json +65 -0
@@ -0,0 +1,884 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { mkdir, symlink, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { ConvexHttpClient } from "convex/browser";
8
+ import { makeFunctionReference } from "convex/server";
9
+ import { catalogActionById } from "@reasonlayer/integrations";
10
+ import { FN, isTerminalRunStatus, LEASE_DEFAULT_MS, LEASE_FLOOR_MS, } from "@reasonlayer/protocol";
11
+ import { FlueTranscriptMapper } from "./flue-transcript.js";
12
+ import { materializeEnvironment, verifyEnvironmentBinaries, } from "./environment-materializer.js";
13
+ import { startMcpServers } from "./mcp-supervisor.js";
14
+ import { createNodeSandboxSessionEnv, killProcessGroup } from "./sandbox-env.js";
15
+ function fn(kind, name) {
16
+ return makeFunctionReference(name);
17
+ }
18
+ export class RunnerConvexClient {
19
+ config;
20
+ client;
21
+ constructor(config) {
22
+ this.config = config;
23
+ this.client = new ConvexHttpClient(config.convexUrl, {
24
+ logger: false,
25
+ skipConvexDeploymentUrlCheck: true,
26
+ });
27
+ }
28
+ query(name, args) {
29
+ return this.client.query(fn("query", name), args);
30
+ }
31
+ mutation(name, args) {
32
+ return this.client.mutation(fn("mutation", name), args, { skipQueue: true });
33
+ }
34
+ action(name, args) {
35
+ return this.client.action(fn("action", name), args);
36
+ }
37
+ }
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+ export function defaultDeviceRunnerId() {
42
+ return `device-${process.pid}`;
43
+ }
44
+ export function defaultOrgRunnerId() {
45
+ return `runner-${process.pid}`;
46
+ }
47
+ // Resolution order mirrors who is expected to provide the package: the
48
+ // project the runner was started in (customers install @flue/runtime in
49
+ // their own repo), then the runner package itself (a direct dependency
50
+ // since M7), so local execution works from any working directory.
51
+ // This walks node_modules on disk rather than using require.resolve —
52
+ // @flue/runtime is ESM-only, so require-condition resolution throws
53
+ // ERR_PACKAGE_PATH_NOT_EXPORTED even when the package is installed, and
54
+ // only the package *directory* is needed for the cache symlink.
55
+ function resolvePackageRoot(specifier) {
56
+ const segments = specifier.split("/");
57
+ const bases = [process.cwd(), dirname(fileURLToPath(import.meta.url))];
58
+ for (const base of bases) {
59
+ for (let dir = base;; dir = dirname(dir)) {
60
+ const candidate = join(dir, "node_modules", ...segments);
61
+ if (existsSync(join(candidate, "package.json"))) {
62
+ try {
63
+ return realpathSync(candidate);
64
+ }
65
+ catch {
66
+ return candidate;
67
+ }
68
+ }
69
+ const parent = dirname(dir);
70
+ if (parent === dir)
71
+ break;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ async function linkFlueRuntimeIntoCache(cacheDir, required) {
77
+ const runtimeRoot = resolvePackageRoot("@flue/runtime");
78
+ if (!runtimeRoot) {
79
+ if (required) {
80
+ throw new Error("@flue/runtime is not resolvable from this host; agent bundles import it " +
81
+ "as an external dependency (spec §7.5). Install it in the project " +
82
+ "running the runner or run `rl dev` from a directory where " +
83
+ "it resolves.");
84
+ }
85
+ return;
86
+ }
87
+ const linkPath = join(cacheDir, "node_modules", "@flue", "runtime");
88
+ if (existsSync(linkPath))
89
+ return;
90
+ await mkdir(dirname(linkPath), { recursive: true });
91
+ try {
92
+ await symlink(runtimeRoot, linkPath, process.platform === "win32" ? "junction" : "dir");
93
+ }
94
+ catch (err) {
95
+ if (err.code !== "EEXIST")
96
+ throw err;
97
+ }
98
+ }
99
+ async function loadBundle(task, cacheDir) {
100
+ if (!task.bundleUrl)
101
+ throw new Error(`task ${task.taskId} has no runner bundle URL`);
102
+ if (!task.bundleSha256)
103
+ throw new Error(`task ${task.taskId} is missing runner bundle sha256`);
104
+ const fetched = await fetch(task.bundleUrl);
105
+ if (!fetched.ok) {
106
+ throw new Error(`cannot fetch runner bundle (HTTP ${fetched.status}): ${await fetched.text()}`);
107
+ }
108
+ const bytes = Buffer.from(await fetched.arrayBuffer());
109
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
110
+ if (task.bundleSha256 !== sha256) {
111
+ throw new Error(`runner bundle sha256 mismatch for task ${task.taskId}`);
112
+ }
113
+ await mkdir(cacheDir, { recursive: true });
114
+ await linkFlueRuntimeIntoCache(cacheDir, task.kind === "agent");
115
+ const file = join(cacheDir, `${sha256}.mjs`);
116
+ await writeFile(file, bytes);
117
+ return (await import(pathToFileURL(file).href));
118
+ }
119
+ export const TASK_ENV_CONCURRENCY = 1;
120
+ export const DEFAULT_DEVICE_POLL_INTERVAL_MS = 30_000;
121
+ let envQueue = Promise.resolve();
122
+ async function withTaskEnv(env, fn) {
123
+ const previousQueue = envQueue;
124
+ let release;
125
+ envQueue = previousQueue.then(() => new Promise((resolve) => (release = resolve)));
126
+ await previousQueue;
127
+ const previous = new Map();
128
+ for (const [key, value] of Object.entries(env)) {
129
+ previous.set(key, process.env[key]);
130
+ process.env[key] = value;
131
+ }
132
+ try {
133
+ return await fn();
134
+ }
135
+ finally {
136
+ for (const [key, value] of previous) {
137
+ if (value === undefined)
138
+ delete process.env[key];
139
+ else
140
+ process.env[key] = value;
141
+ }
142
+ release();
143
+ }
144
+ }
145
+ const MAX_TRANSCRIPT_BATCH_EVENTS = 20;
146
+ const TRANSCRIPT_FLUSH_INTERVAL_MS = 1000;
147
+ const MAX_TRANSCRIPT_STRING_CHARS = 4096;
148
+ const TRUNCATED = "[truncated]";
149
+ function truncateString(value) {
150
+ if (value.length <= MAX_TRANSCRIPT_STRING_CHARS)
151
+ return value;
152
+ return `${value.slice(0, MAX_TRANSCRIPT_STRING_CHARS - TRUNCATED.length)}${TRUNCATED}`;
153
+ }
154
+ function sanitizeTranscriptValue(value, secrets) {
155
+ if (typeof value === "string") {
156
+ const redacted = secrets.reduce((acc, secret) => (secret ? acc.split(secret).join("[redacted]") : acc), value);
157
+ return truncateString(redacted);
158
+ }
159
+ if (Array.isArray(value))
160
+ return value.map((item) => sanitizeTranscriptValue(item, secrets));
161
+ if (value && typeof value === "object") {
162
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
163
+ key,
164
+ sanitizeTranscriptValue(nested, secrets),
165
+ ]));
166
+ }
167
+ return value;
168
+ }
169
+ export class TranscriptReporter {
170
+ client;
171
+ claim;
172
+ secrets;
173
+ buffer = [];
174
+ flushing = Promise.resolve();
175
+ flushTimer;
176
+ constructor(client, claim, secrets) {
177
+ this.client = client;
178
+ this.claim = claim;
179
+ this.secrets = secrets;
180
+ this.flushTimer = setInterval(() => void this.flush(), TRANSCRIPT_FLUSH_INTERVAL_MS);
181
+ this.flushTimer.unref?.();
182
+ }
183
+ async push(event) {
184
+ this.buffer.push(sanitizeTranscriptValue(event, this.secrets));
185
+ if (this.buffer.length >= MAX_TRANSCRIPT_BATCH_EVENTS)
186
+ await this.flush();
187
+ }
188
+ async flush() {
189
+ if (this.buffer.length === 0)
190
+ return;
191
+ const batch = this.buffer.splice(0, this.buffer.length);
192
+ this.flushing = this.flushing
193
+ .then(() => this.client.mutation(FN.transcriptsIngest, {
194
+ taskId: this.claim.task.taskId,
195
+ taskToken: this.claim.taskToken,
196
+ events: batch,
197
+ }))
198
+ .catch((err) => {
199
+ console.error(`[reasonlayer-runner] transcript flush failed: ${err.message}`);
200
+ });
201
+ await this.flushing;
202
+ }
203
+ async close() {
204
+ clearInterval(this.flushTimer);
205
+ await this.flush();
206
+ await this.flushing;
207
+ }
208
+ }
209
+ async function executeCodeTask(module, task) {
210
+ const runs = module["STEP_RUNS"];
211
+ const run = runs?.[task.entryStepId] ??
212
+ module[task.exportName];
213
+ if (typeof run !== "function")
214
+ throw new Error(`code export not found for ${task.entryStepId}`);
215
+ return await withTaskEnv(task.env, async () => await run(task.input));
216
+ }
217
+ async function executeAgentTask(module, task, reporter, signal) {
218
+ if (task.framework !== "flue") {
219
+ throw new Error(`unsupported agent framework: ${task.framework ?? "unknown"}`);
220
+ }
221
+ const agents = module["AGENT_RUNS"];
222
+ const agent = agents?.[task.entryStepId] ?? module[task.exportName];
223
+ if (!agent)
224
+ throw new Error(`agent export not found for ${task.entryStepId}`);
225
+ const catalogTools = catalogToolDefinitions(task.catalogTools);
226
+ if (typeof agent === "function") {
227
+ if (catalogTools.length > 0) {
228
+ throw new Error(`inline function agent ${task.entryStepId} cannot use catalog tools; use a Flue defineAgent() definition`);
229
+ }
230
+ return await withTaskEnv(task.env, async () => await agent(task.input));
231
+ }
232
+ const mcp = await startMcpServers(task.environment, { taskEnv: task.env, signal });
233
+ const env = { ...task.env, ...mcp.env };
234
+ try {
235
+ return await withTaskEnv(env, async () => await runFlueStep(withMcpTools(agent, [...catalogTools, ...mcp.tools]), task.input, {
236
+ env,
237
+ signal,
238
+ emit: (event) => reporter.push(event),
239
+ runId: task.runId,
240
+ stepId: task.stepId,
241
+ outputSchemaJsonText: task.outputSchemaJsonText,
242
+ host: task.host,
243
+ }));
244
+ }
245
+ finally {
246
+ await mcp.close();
247
+ }
248
+ }
249
+ export function catalogToolDefinitions(actionIds) {
250
+ return actionIds.map((actionId) => {
251
+ const descriptor = catalogActionById(actionId);
252
+ if (!descriptor) {
253
+ throw new Error(`unknown catalog tool "${actionId}" in agent task`);
254
+ }
255
+ return {
256
+ name: descriptor.id,
257
+ description: descriptor.description,
258
+ input: descriptor.input,
259
+ output: descriptor.output,
260
+ run: async ({ input }) => await descriptor(input),
261
+ };
262
+ });
263
+ }
264
+ /**
265
+ * Inject runner-owned tools into the agent's resolved runtime config — the
266
+ * definition alone can't know them (the runner owns catalog access and MCP
267
+ * server startup/port allocation).
268
+ */
269
+ export function withMcpTools(agent, tools) {
270
+ if (tools.length === 0)
271
+ return agent;
272
+ const definition = agent;
273
+ if (typeof definition?.initialize !== "function") {
274
+ throw new Error("declared MCP servers require a defineAgent() agent definition");
275
+ }
276
+ const initialize = definition.initialize.bind(definition);
277
+ return {
278
+ __flueAgentDefinition: true,
279
+ initialize: async (context) => {
280
+ const config = (await initialize(context));
281
+ return { ...config, tools: [...(config.tools ?? []), ...tools] };
282
+ },
283
+ };
284
+ }
285
+ export function createDefaultFlueSessionEnv(_host, cwd = process.cwd()) {
286
+ return createNodeSandboxSessionEnv(cwd);
287
+ }
288
+ async function runFlueStep(agent, input, context) {
289
+ const runtimeSpecifier = "@flue/runtime";
290
+ const internalSpecifier = "@flue/runtime/internal";
291
+ const nodeSpecifier = "@flue/runtime/node";
292
+ const runtime = (await import(runtimeSpecifier));
293
+ const internal = (await import(internalSpecifier));
294
+ const node = (await import(nodeSpecifier));
295
+ const adapter = node.sqlite();
296
+ if (adapter.migrate)
297
+ await adapter.migrate();
298
+ const { executionStore } = await adapter.connect();
299
+ const activityGate = internal.createRuntimeActivityGate();
300
+ const env = { ...process.env, ...context.env };
301
+ const createDefaultEnv = async () => createDefaultFlueSessionEnv(context.host);
302
+ const coordinator = internal.createNodeAgentCoordinator({
303
+ submissions: executionStore.submissions,
304
+ agents: [{ name: "reasonlayer-agent", definition: agent }],
305
+ activityGate,
306
+ createContext: ({ id, agentName, request, initialEventIndex, dispatchId }) => internal.createFlueContext({
307
+ id,
308
+ agentName,
309
+ dispatchId,
310
+ initialEventIndex,
311
+ env,
312
+ req: request,
313
+ agentConfig: { resolveModel: internal.resolveModel },
314
+ createDefaultEnv,
315
+ }),
316
+ });
317
+ const runtimeAdapter = {
318
+ observe: runtime.observe,
319
+ createInstrumentationOwner: internal.createInstrumentationOwner,
320
+ runWithInstrumentationOwner: internal.runWithInstrumentationOwner,
321
+ };
322
+ return await runFlueStepWithCoordinator(input, context, {
323
+ runtime: runtimeAdapter,
324
+ coordinator,
325
+ });
326
+ }
327
+ export async function runFlueStepWithCoordinator(input, context, adapters) {
328
+ const mapper = new FlueTranscriptMapper();
329
+ const { runtime, coordinator } = adapters;
330
+ const owner = runtime.createInstrumentationOwner();
331
+ const instanceId = `${context.runId}:${context.stepId}`;
332
+ const keepAlive = setInterval(() => { }, 1000);
333
+ const onAbort = () => void coordinator.abortInstance("reasonlayer-agent", instanceId).catch(() => { });
334
+ context.signal.addEventListener("abort", onAbort, { once: true });
335
+ let terminalErrorEmitted = false;
336
+ try {
337
+ return await runtime.runWithInstrumentationOwner(owner, async () => {
338
+ let lastPromptOperation;
339
+ let maxSeq = -1;
340
+ const unsubscribe = runtime.observe((event) => {
341
+ if (typeof event.eventIndex === "number" && event.eventIndex > maxSeq)
342
+ maxSeq = event.eventIndex;
343
+ if (event.type === "operation" && event.operationKind === "prompt") {
344
+ lastPromptOperation = event;
345
+ }
346
+ for (const mapped of mapper.map(event))
347
+ void context.emit(mapped);
348
+ });
349
+ try {
350
+ const admit = coordinator.createAdmission("reasonlayer-agent", instanceId);
351
+ void admit({ message: toMessage(input) }, () => { }, true).catch(() => { });
352
+ await coordinator.waitForIdle();
353
+ if (lastPromptOperation?.isError) {
354
+ terminalErrorEmitted = true;
355
+ await context.emit(mapper.terminalError(maxSeq + 1, lastPromptOperation.error));
356
+ throw new Error(String(lastPromptOperation.error?.message ?? lastPromptOperation.error));
357
+ }
358
+ return extractOutput(lastPromptOperation?.result, context.outputSchemaJsonText);
359
+ }
360
+ catch (err) {
361
+ if (!terminalErrorEmitted) {
362
+ terminalErrorEmitted = true;
363
+ await context.emit(mapper.terminalError(maxSeq + 1, err));
364
+ }
365
+ throw err;
366
+ }
367
+ finally {
368
+ unsubscribe();
369
+ }
370
+ });
371
+ }
372
+ finally {
373
+ context.signal.removeEventListener("abort", onAbort);
374
+ clearInterval(keepAlive);
375
+ await coordinator.shutdown(0).catch(() => { });
376
+ }
377
+ }
378
+ function outputSchemaType(outputSchemaJsonText) {
379
+ try {
380
+ const parsed = JSON.parse(outputSchemaJsonText);
381
+ return typeof parsed.type === "string" ? parsed.type : undefined;
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ }
387
+ export function extractOutput(result, outputSchemaJsonText) {
388
+ const shouldParseJsonText = outputSchemaType(outputSchemaJsonText) !== "string";
389
+ const parseJsonText = (value) => {
390
+ if (typeof value !== "string")
391
+ return value;
392
+ if (!shouldParseJsonText)
393
+ return value;
394
+ const trimmed = value.trim();
395
+ try {
396
+ return JSON.parse(trimmed);
397
+ }
398
+ catch {
399
+ return value;
400
+ }
401
+ };
402
+ if (result && typeof result === "object") {
403
+ const record = result;
404
+ // Flue may surface model text through either field; parse JSON text
405
+ // uniformly (schema-aware) so a string payload validates against
406
+ // object/array output schemas regardless of which field carried it.
407
+ if (record.data !== undefined)
408
+ return parseJsonText(record.data);
409
+ if ("text" in record)
410
+ return parseJsonText(record.text);
411
+ }
412
+ return parseJsonText(result);
413
+ }
414
+ function toMessage(input) {
415
+ return typeof input === "string" ? input : JSON.stringify(input ?? null);
416
+ }
417
+ class TaskAbortError extends Error {
418
+ }
419
+ async function runAbortable(signal, fn) {
420
+ if (signal.aborted)
421
+ throw new TaskAbortError("task aborted");
422
+ let onAbort;
423
+ try {
424
+ return await Promise.race([
425
+ fn(),
426
+ new Promise((_resolve, reject) => {
427
+ onAbort = () => reject(new TaskAbortError("task aborted"));
428
+ signal.addEventListener("abort", onAbort, { once: true });
429
+ }),
430
+ ]);
431
+ }
432
+ finally {
433
+ if (onAbort)
434
+ signal.removeEventListener("abort", onAbort);
435
+ }
436
+ }
437
+ function integrationTaskEnv(claim, convexUrl) {
438
+ return {
439
+ RL_TASK_ID: claim.task.taskId,
440
+ RL_TASK_TOKEN: claim.taskToken,
441
+ RL_CONVEX_URL: convexUrl,
442
+ };
443
+ }
444
+ export async function executeClaimTask(claim, options) {
445
+ if (claim.task.kind === "action") {
446
+ throw new Error("catalog actions execute through the backend provider boundary");
447
+ }
448
+ let task = claim.task;
449
+ if (options.verifyDeviceEnvironments &&
450
+ task.host.kind === "device" &&
451
+ task.environment) {
452
+ await verifyEnvironmentBinaries(task.environment);
453
+ }
454
+ if (options.materializeEnvironments && task.environment) {
455
+ const materialized = await materializeEnvironment(task.environment);
456
+ task = {
457
+ ...task,
458
+ env: {
459
+ ...task.env,
460
+ ...materialized.env,
461
+ },
462
+ };
463
+ }
464
+ task = {
465
+ ...task,
466
+ env: {
467
+ ...task.env,
468
+ ...integrationTaskEnv(claim, options.convexUrl),
469
+ },
470
+ };
471
+ const module = await loadBundle(task, options.cacheDir ?? join(process.cwd(), ".rl", "runner-cache"));
472
+ return await runAbortable(options.signal, async () => task.kind === "agent"
473
+ ? await executeAgentTask(module, task, options.reporter, options.signal)
474
+ : await executeCodeTask(module, task));
475
+ }
476
+ export function createInProcessTaskProcessAdapter() {
477
+ return {
478
+ run: async (claim, options) => await executeClaimTask(claim, options),
479
+ };
480
+ }
481
+ function childEntryPath() {
482
+ return fileURLToPath(new URL("./task-child.js", import.meta.url));
483
+ }
484
+ function resultError(result) {
485
+ const error = new Error(result.message);
486
+ if (result.stack)
487
+ error.stack = result.stack;
488
+ return error;
489
+ }
490
+ async function runTaskChildProcess(claim, options, childPath = childEntryPath()) {
491
+ if (options.signal.aborted)
492
+ throw new TaskAbortError("task aborted");
493
+ if (claim.task.kind === "action") {
494
+ throw new Error("catalog actions cannot enter the task subprocess");
495
+ }
496
+ const child = spawn(process.execPath, [childPath], {
497
+ detached: process.platform !== "win32",
498
+ env: {
499
+ ...process.env,
500
+ ...claim.task.env,
501
+ ...integrationTaskEnv(claim, options.convexUrl),
502
+ },
503
+ stdio: ["ignore", "inherit", "inherit", "ipc"],
504
+ });
505
+ let killTimer;
506
+ let resultReceived = false;
507
+ let settled = false;
508
+ return await new Promise((resolve, reject) => {
509
+ const cleanup = () => {
510
+ options.signal.removeEventListener("abort", onAbort);
511
+ if (killTimer)
512
+ clearTimeout(killTimer);
513
+ };
514
+ const settle = (fn) => {
515
+ if (settled)
516
+ return;
517
+ settled = true;
518
+ cleanup();
519
+ fn();
520
+ };
521
+ const onAbort = () => {
522
+ killProcessGroup(child, "SIGTERM");
523
+ killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), 5_000);
524
+ killTimer.unref?.();
525
+ };
526
+ options.signal.addEventListener("abort", onAbort, { once: true });
527
+ child.once("error", (err) => settle(() => reject(err)));
528
+ child.once("exit", (code, signal) => {
529
+ if (resultReceived)
530
+ return;
531
+ settle(() => reject(new Error(`task child exited before reporting a result (code ${code ?? "null"}, signal ${signal ?? "null"})`)));
532
+ });
533
+ child.on("message", (message) => {
534
+ if (!message || typeof message !== "object")
535
+ return;
536
+ if (message.type === "transcript") {
537
+ void options.reporter.push(message.event);
538
+ return;
539
+ }
540
+ if (message.type === "result") {
541
+ resultReceived = true;
542
+ const result = message.result;
543
+ if (result.kind === "ok") {
544
+ settle(() => resolve(result.output));
545
+ }
546
+ else if (result.kind === "error") {
547
+ settle(() => reject(resultError(result)));
548
+ }
549
+ else {
550
+ settle(() => reject(new TaskAbortError("task aborted")));
551
+ }
552
+ }
553
+ });
554
+ child.send?.({
555
+ type: "run",
556
+ claim,
557
+ options: {
558
+ convexUrl: options.convexUrl,
559
+ ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}),
560
+ ...(options.materializeEnvironments !== undefined
561
+ ? { materializeEnvironments: options.materializeEnvironments }
562
+ : {}),
563
+ ...(options.verifyDeviceEnvironments !== undefined
564
+ ? { verifyDeviceEnvironments: options.verifyDeviceEnvironments }
565
+ : {}),
566
+ },
567
+ });
568
+ if (options.signal.aborted)
569
+ onAbort();
570
+ });
571
+ }
572
+ export function createSubprocessTaskProcessAdapter(options = {}) {
573
+ return {
574
+ run: async (claim, runOptions) => await runTaskChildProcess(claim, runOptions, options.childEntryPath),
575
+ };
576
+ }
577
+ function heartbeatIntervalMs(options) {
578
+ const raw = options.heartbeatIntervalMs ??
579
+ Math.floor((options.leaseMs ?? LEASE_DEFAULT_MS) / 3);
580
+ if (!Number.isFinite(raw))
581
+ return LEASE_FLOOR_MS;
582
+ return Math.max(LEASE_FLOOR_MS, Math.floor(raw));
583
+ }
584
+ export async function runClaim(config, claim, options = {}) {
585
+ const client = options.client ?? new RunnerConvexClient(config);
586
+ const controller = new AbortController();
587
+ let cancelRequested = false;
588
+ const onExternalAbort = () => {
589
+ cancelRequested = true;
590
+ controller.abort();
591
+ };
592
+ if (options.signal?.aborted) {
593
+ onExternalAbort();
594
+ }
595
+ else {
596
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
597
+ }
598
+ const heartbeatMs = heartbeatIntervalMs(options);
599
+ const heartbeat = setInterval(() => {
600
+ void client
601
+ .mutation(FN.tasksHeartbeat, {
602
+ taskId: claim.task.taskId,
603
+ taskToken: claim.taskToken,
604
+ leaseMs: options.leaseMs,
605
+ })
606
+ .then((status) => {
607
+ if (status.cancelRequested) {
608
+ cancelRequested = true;
609
+ controller.abort();
610
+ }
611
+ })
612
+ .catch(() => { });
613
+ }, heartbeatMs);
614
+ let timedOut = false;
615
+ const timeout = claim.task.timeoutMs
616
+ ? setTimeout(() => {
617
+ timedOut = true;
618
+ controller.abort();
619
+ }, claim.task.timeoutMs)
620
+ : undefined;
621
+ const reporter = new TranscriptReporter(client, claim, claim.task.kind === "action" ? [] : Object.values(claim.task.env));
622
+ const processAdapter = options.processAdapter ?? createSubprocessTaskProcessAdapter();
623
+ let result;
624
+ try {
625
+ const output = claim.task.kind === "action"
626
+ // The runner owns task lifecycle; Convex owns provider I/O so catalog
627
+ // credentials and connection references never enter the subprocess.
628
+ ? await runAbortable(controller.signal, async () => await client.action(FN.integrationsExecuteTaskAction, {
629
+ taskId: claim.task.taskId,
630
+ taskToken: claim.taskToken,
631
+ }))
632
+ : await processAdapter.run(claim, {
633
+ convexUrl: config.convexUrl,
634
+ ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}),
635
+ ...(options.materializeEnvironments !== undefined
636
+ ? { materializeEnvironments: options.materializeEnvironments }
637
+ : {}),
638
+ ...(options.verifyDeviceEnvironments !== undefined
639
+ ? { verifyDeviceEnvironments: options.verifyDeviceEnvironments }
640
+ : {}),
641
+ signal: controller.signal,
642
+ reporter,
643
+ });
644
+ result = { kind: "ok", output };
645
+ }
646
+ catch (err) {
647
+ if (cancelRequested)
648
+ result = { kind: "canceled" };
649
+ else if (timedOut) {
650
+ result = {
651
+ kind: "error",
652
+ message: `task timed out after ${claim.task.timeoutMs}ms`,
653
+ };
654
+ }
655
+ else {
656
+ result = { kind: "error", message: err instanceof Error ? err.message : String(err) };
657
+ }
658
+ }
659
+ finally {
660
+ options.signal?.removeEventListener("abort", onExternalAbort);
661
+ clearInterval(heartbeat);
662
+ if (timeout)
663
+ clearTimeout(timeout);
664
+ await reporter.close();
665
+ }
666
+ await client.mutation(FN.tasksComplete, {
667
+ taskId: claim.task.taskId,
668
+ taskToken: claim.taskToken,
669
+ result,
670
+ });
671
+ return result;
672
+ }
673
+ export function runScopedSource(options) {
674
+ return {
675
+ label: "run claim",
676
+ async claim(client, runnerId, leaseMs) {
677
+ return await client.mutation(FN.tasksClaimNextForRun, {
678
+ runId: options.runId,
679
+ executorToken: options.executorToken,
680
+ runnerId,
681
+ leaseMs,
682
+ });
683
+ },
684
+ };
685
+ }
686
+ export function deviceJwtSource(options) {
687
+ if (!options.deviceToken && !options.deviceTokenProvider) {
688
+ throw new Error("device claim loop requires deviceToken or deviceTokenProvider");
689
+ }
690
+ return {
691
+ label: "device claim",
692
+ taskOptions: { verifyDeviceEnvironments: true },
693
+ async claim(client, runnerId, leaseMs) {
694
+ const deviceToken = options.deviceTokenProvider
695
+ ? await options.deviceTokenProvider()
696
+ : options.deviceToken;
697
+ return await client.mutation(FN.tasksClaimDeviceNext, {
698
+ deviceToken,
699
+ runnerId,
700
+ leaseMs,
701
+ });
702
+ },
703
+ };
704
+ }
705
+ export function dispatchTokenSource(options) {
706
+ let claimed = false;
707
+ return {
708
+ label: "dispatch claim",
709
+ stopWhenIdle: true,
710
+ async claim(client, runnerId, leaseMs) {
711
+ if (claimed)
712
+ return null;
713
+ if (options.sandboxId !== undefined) {
714
+ await client.mutation(FN.tasksRecordSpawned, {
715
+ taskId: options.taskId,
716
+ dispatchToken: options.dispatchToken,
717
+ sandboxId: options.sandboxId,
718
+ });
719
+ }
720
+ const claim = await client.mutation(FN.tasksClaimDispatched, {
721
+ taskId: options.taskId,
722
+ dispatchToken: options.dispatchToken,
723
+ runnerId,
724
+ leaseMs,
725
+ });
726
+ claimed = true;
727
+ return claim;
728
+ },
729
+ };
730
+ }
731
+ export async function runTaskLoop(options) {
732
+ const client = options.client ?? new RunnerConvexClient(options);
733
+ const runnerId = options.runnerId ?? defaultOrgRunnerId();
734
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
735
+ const executor = options.executor ??
736
+ {
737
+ run: async (claim, runOptions) => await runClaim(options, claim, runOptions),
738
+ };
739
+ let lastResult;
740
+ for (;;) {
741
+ try {
742
+ const claim = await options.source.claim(client, runnerId, options.leaseMs);
743
+ if (claim) {
744
+ lastResult = await executor.run(claim, claimOptions(options, options.source.taskOptions));
745
+ if (options.once || options.source.stopWhenIdle)
746
+ return lastResult;
747
+ continue;
748
+ }
749
+ if (options.once || options.source.stopWhenIdle)
750
+ return lastResult;
751
+ }
752
+ catch (err) {
753
+ const message = err instanceof Error ? err.message : String(err);
754
+ console.error(`[reasonlayer-runner] ${options.source.label} poll failed: ${message}`);
755
+ }
756
+ await sleep(pollIntervalMs);
757
+ }
758
+ }
759
+ export async function runTestRun(options) {
760
+ const client = options.client ?? new RunnerConvexClient(options);
761
+ const runnerId = options.runnerId ?? defaultOrgRunnerId();
762
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
763
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
764
+ const source = runScopedSource({
765
+ runId: options.runId,
766
+ executorToken: options.executorToken,
767
+ });
768
+ const executor = options.executor ??
769
+ {
770
+ run: async (claim, runOptions) => await runClaim(options, claim, runOptions),
771
+ };
772
+ const inFlight = new Set();
773
+ let cancelPromise;
774
+ // In-flight tasks must abort both when the CLI is interrupted and when the
775
+ // run goes terminal underneath them (external cancel / sibling failure) —
776
+ // they report their canceled result before the process exits.
777
+ const taskAbort = new AbortController();
778
+ if (options.signal?.aborted)
779
+ taskAbort.abort();
780
+ else
781
+ options.signal?.addEventListener("abort", () => taskAbort.abort(), { once: true });
782
+ const getRun = async () => await client.query(FN.runsGet, {
783
+ token: options.token,
784
+ runId: options.runId,
785
+ });
786
+ const requestCancel = () => {
787
+ if (cancelPromise)
788
+ return;
789
+ cancelPromise = client
790
+ .mutation(FN.runsCancel, { token: options.token, runId: options.runId })
791
+ .catch((err) => {
792
+ const message = err instanceof Error ? err.message : String(err);
793
+ console.error(`[reasonlayer-runner] test run cancel failed: ${message}`);
794
+ });
795
+ };
796
+ const startClaim = async () => {
797
+ const claim = await source.claim(client, runnerId, options.leaseMs);
798
+ if (!claim)
799
+ return false;
800
+ const task = executor
801
+ .run(claim, {
802
+ ...claimOptions(options),
803
+ materializeEnvironments: true,
804
+ signal: taskAbort.signal,
805
+ })
806
+ .catch((err) => {
807
+ const message = err instanceof Error ? err.message : String(err);
808
+ console.error(`[reasonlayer-runner] test task execution failed: ${message}`);
809
+ })
810
+ .then(() => undefined);
811
+ inFlight.add(task);
812
+ task.finally(() => inFlight.delete(task));
813
+ return true;
814
+ };
815
+ for (;;) {
816
+ if (options.signal?.aborted)
817
+ requestCancel();
818
+ while (!options.signal?.aborted && inFlight.size < concurrency) {
819
+ try {
820
+ const claimed = await startClaim();
821
+ if (!claimed)
822
+ break;
823
+ }
824
+ catch (err) {
825
+ const message = err instanceof Error ? err.message : String(err);
826
+ console.error(`[reasonlayer-runner] ${source.label} poll failed: ${message}`);
827
+ break;
828
+ }
829
+ }
830
+ const run = await getRun();
831
+ if (isTerminalRunStatus(run.status)) {
832
+ if (inFlight.size > 0) {
833
+ taskAbort.abort();
834
+ await Promise.race([Promise.allSettled([...inFlight]), sleep(10_000)]);
835
+ }
836
+ return run;
837
+ }
838
+ if (inFlight.size === 0) {
839
+ await sleep(pollIntervalMs);
840
+ }
841
+ else {
842
+ await Promise.race([...inFlight, sleep(pollIntervalMs)]);
843
+ }
844
+ }
845
+ }
846
+ export async function runDispatchedTask(options) {
847
+ const result = await runTaskLoop({
848
+ ...options,
849
+ runnerId: options.runnerId ?? defaultOrgRunnerId(),
850
+ once: true,
851
+ source: dispatchTokenSource({
852
+ taskId: options.taskId,
853
+ dispatchToken: options.dispatchToken,
854
+ ...(options.sandboxId !== undefined ? { sandboxId: options.sandboxId } : {}),
855
+ }),
856
+ });
857
+ if (!result)
858
+ throw new Error("dispatched task was not claimed");
859
+ return result;
860
+ }
861
+ export async function runDeviceClaimLoop(options) {
862
+ await runTaskLoop({
863
+ ...options,
864
+ pollIntervalMs: options.pollIntervalMs ?? DEFAULT_DEVICE_POLL_INTERVAL_MS,
865
+ runnerId: options.runnerId ?? defaultDeviceRunnerId(),
866
+ source: deviceJwtSource(options),
867
+ });
868
+ }
869
+ function claimOptions(options, sourceOptions = {}) {
870
+ return {
871
+ ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}),
872
+ ...(options.leaseMs !== undefined ? { leaseMs: options.leaseMs } : {}),
873
+ ...(options.materializeEnvironments !== undefined
874
+ ? { materializeEnvironments: options.materializeEnvironments }
875
+ : {}),
876
+ ...(options.verifyDeviceEnvironments !== undefined
877
+ ? { verifyDeviceEnvironments: options.verifyDeviceEnvironments }
878
+ : {}),
879
+ ...(sourceOptions.verifyDeviceEnvironments !== undefined
880
+ ? { verifyDeviceEnvironments: sourceOptions.verifyDeviceEnvironments }
881
+ : {}),
882
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
883
+ };
884
+ }