@nylorun/runtime 0.4.0-beta → 0.6.0-beta

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 (64) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +29 -47
  3. package/dist/adapters/media.d.ts +2 -18
  4. package/dist/adapters/media.js +2 -52
  5. package/dist/adapters/observe.js +1 -1
  6. package/dist/config.d.ts +17 -10
  7. package/dist/configuration.d.ts +3 -0
  8. package/dist/configuration.js +3 -0
  9. package/dist/contracts.d.ts +5 -166
  10. package/dist/core/main.js +40 -0
  11. package/dist/core/provider.d.ts +12 -0
  12. package/dist/core/provider.js +60 -0
  13. package/dist/core/runtime.d.ts +50 -0
  14. package/dist/core/runtime.js +877 -0
  15. package/dist/core/store.d.ts +16 -0
  16. package/dist/core/store.js +101 -0
  17. package/dist/index.d.ts +6 -11
  18. package/dist/index.js +4 -6
  19. package/dist/media.d.ts +29 -0
  20. package/dist/media.js +53 -0
  21. package/dist/model/defaults.d.ts +17 -0
  22. package/dist/model/defaults.js +21 -0
  23. package/dist/model/http-model.d.ts +12 -0
  24. package/dist/model/http-model.js +299 -0
  25. package/dist/model/pi-model.d.ts +2 -1
  26. package/dist/model/pi-model.js +52 -7
  27. package/dist/node/index.d.ts +5 -0
  28. package/dist/node/index.js +5 -0
  29. package/dist/node/local-sessions.d.ts +5 -0
  30. package/dist/node/local-sessions.js +174 -0
  31. package/dist/redact.d.ts +1 -0
  32. package/dist/redact.js +14 -0
  33. package/dist/server/ag-ui.d.ts +1 -1
  34. package/dist/server/delivery.d.ts +24 -0
  35. package/dist/server/delivery.js +107 -0
  36. package/dist/server/host.d.ts +50 -7
  37. package/dist/server/host.js +304 -307
  38. package/dist/session/api.d.ts +10 -0
  39. package/dist/session/api.js +15 -0
  40. package/dist/session/default.d.ts +5 -0
  41. package/dist/session/default.js +30 -0
  42. package/dist/session/handle.d.ts +27 -0
  43. package/dist/session/handle.js +199 -0
  44. package/dist/session/index.d.ts +2 -0
  45. package/dist/session/index.js +2 -0
  46. package/dist/sessions/host.d.ts +39 -0
  47. package/dist/sessions/host.js +359 -0
  48. package/dist/sessions/store.d.ts +41 -0
  49. package/dist/sessions/store.js +33 -0
  50. package/package.json +23 -12
  51. package/dist/adapters/journal.d.ts +0 -35
  52. package/dist/adapters/journal.js +0 -130
  53. package/dist/cli.d.ts +0 -2
  54. package/dist/cli.js +0 -100
  55. package/dist/dev-entry.js +0 -2
  56. package/dist/dev.d.ts +0 -2
  57. package/dist/dev.js +0 -126
  58. package/dist/environment.d.ts +0 -2
  59. package/dist/environment.js +0 -64
  60. package/dist/launcher.d.ts +0 -1
  61. package/dist/launcher.js +0 -28
  62. package/dist/model/configure.d.ts +0 -12
  63. package/dist/model/configure.js +0 -155
  64. /package/dist/{dev-entry.d.ts → core/main.d.ts} +0 -0
@@ -0,0 +1,877 @@
1
+ import { createServer, } from "node:http";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import { AgentManifestSchema, PutAgentRequestSchema, PutSessionRequestSchema, SessionCommandSchema, ActionClaimRequestSchema, ActionHeartbeatRequestSchema, } from "@nylorun/core/contracts";
6
+ import { createDurableCheckpoint, runDurable, } from "@nylorun/harness/run";
7
+ import { hashManifest } from "@nylorun/core/compatibility";
8
+ import { Store, canonical } from "./store.js";
9
+ import { scriptedModel } from "./provider.js";
10
+ class HttpError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ const fail = (status, message) => {
18
+ throw new HttpError(status, message);
19
+ };
20
+ const semantic = (value) => {
21
+ const { requestId: _, ...body } = value;
22
+ return canonical(body);
23
+ };
24
+ const equals = (a, b) => {
25
+ const aa = Buffer.from(a), bb = Buffer.from(b);
26
+ return aa.length === bb.length && timingSafeEqual(aa, bb);
27
+ };
28
+ export class CoreRuntime {
29
+ options;
30
+ store;
31
+ running = new Map();
32
+ pending = new Set();
33
+ observers = new Map();
34
+ executors = new Set();
35
+ server;
36
+ closing = false;
37
+ lockPath;
38
+ timer;
39
+ constructor(options) {
40
+ this.options = options;
41
+ if (options.leaseMs !== undefined &&
42
+ (!Number.isFinite(options.leaseMs) || options.leaseMs <= 0))
43
+ throw new Error("leaseMs must be finite and positive");
44
+ if (new Set(options.executors.map((e) => e.token)).size !==
45
+ options.executors.length)
46
+ throw new Error("Executor tokens must be unique");
47
+ if (!options.serverToken || options.serverToken.length < 16)
48
+ throw new Error("A server token of at least 16 characters is required");
49
+ if (options.executors.some((e) => e.token.length < 16 ||
50
+ equals(e.token, options.serverToken) ||
51
+ !e.agentId ||
52
+ !e.manifestHash ||
53
+ !e.implementationVersion))
54
+ throw new Error("Executor tokens require independent credentials and exact definition scope");
55
+ if (options.sqlitePath !== ":memory:") {
56
+ mkdirSync(dirname(options.sqlitePath), { recursive: true });
57
+ this.lockPath = options.sqlitePath + ".runtime-lock";
58
+ // One local scheduling owner; recover a stale process lock only after checking that its PID is absent.
59
+ try {
60
+ const oldPid = Number(readFileSync(this.lockPath, "utf8"));
61
+ if (!Number.isSafeInteger(oldPid) || oldPid < 1)
62
+ throw new Error("Invalid runtime lock; inspect before removing");
63
+ try {
64
+ process.kill(oldPid, 0);
65
+ throw new Error("Another Runtime owns this SQLite database");
66
+ }
67
+ catch (error) {
68
+ if (error.code !== "ESRCH")
69
+ throw error;
70
+ unlinkSync(this.lockPath);
71
+ }
72
+ }
73
+ catch (error) {
74
+ if (error.code !== "ENOENT")
75
+ throw error;
76
+ }
77
+ const fd = openSync(this.lockPath, "wx");
78
+ writeFileSync(fd, String(process.pid));
79
+ closeSync(fd);
80
+ }
81
+ try {
82
+ this.store = new Store(options.sqlitePath);
83
+ }
84
+ catch (e) {
85
+ if (this.lockPath)
86
+ unlinkSync(this.lockPath);
87
+ throw e;
88
+ }
89
+ this.store.tx(() => {
90
+ for (const effect of this.store.all("effects"))
91
+ if (effect.status === "invoking") {
92
+ effect.status = "uncertain";
93
+ this.store.put("effects", effect.request.effectId, effect);
94
+ const s = this.store.get("sessions", effect.request.sessionId);
95
+ if (s &&
96
+ s.status !== "cancelled" &&
97
+ s.activeTurnId === effect.request.turnId) {
98
+ s.status = "uncertain";
99
+ this.store.put("sessions", s.id, s);
100
+ this.store.event(s.id, s.activeTurnId, "effect.uncertain", {
101
+ effectId: effect.request.effectId,
102
+ });
103
+ }
104
+ }
105
+ });
106
+ this.expireClaims();
107
+ this.timer = setInterval(() => this.expireClaims(), Math.min(options.leaseMs ?? 30000, 5000));
108
+ this.timer.unref();
109
+ for (const s of this.store.all("sessions"))
110
+ if (s.status === "running" || s.status === "runnable")
111
+ this.schedule(s.id);
112
+ }
113
+ session(id) {
114
+ return (this.store.get("sessions", id) ?? fail(404, "Session not found"));
115
+ }
116
+ publish(event) {
117
+ for (const response of this.observers.get(event.sessionId) ?? [])
118
+ this.send(response, `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
119
+ }
120
+ send(response, data) {
121
+ if (!response.write(data))
122
+ response.destroy();
123
+ }
124
+ notify() {
125
+ for (const response of this.executors)
126
+ this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
127
+ }
128
+ expireClaims() {
129
+ const events = [];
130
+ this.store.tx(() => {
131
+ for (const action of this.store.all("actions"))
132
+ if (action.status === "claimed" &&
133
+ Date.parse(action.leaseExpiresAt) <= Date.now()) {
134
+ action.status = "uncertain";
135
+ this.store.put("actions", action.actionId, action);
136
+ const effect = this.store.get("effects", action.actionId);
137
+ effect.status = "uncertain";
138
+ this.store.put("effects", action.actionId, effect);
139
+ const s = this.session(action.sessionId);
140
+ if (s.status !== "cancelled" && s.activeTurnId === action.turnId) {
141
+ s.status = "uncertain";
142
+ this.store.put("sessions", s.id, s);
143
+ events.push(this.store.event(s.id, s.activeTurnId, "action.uncertain", {
144
+ actionId: action.actionId,
145
+ }));
146
+ }
147
+ }
148
+ });
149
+ events.forEach((e) => this.publish(e));
150
+ }
151
+ schedule(id) {
152
+ if (this.closing)
153
+ return;
154
+ this.pending.add(id);
155
+ setImmediate(() => {
156
+ if (!this.running.has(id) && this.pending.delete(id) && !this.closing)
157
+ void this.execute(id);
158
+ });
159
+ }
160
+ async execute(id) {
161
+ const s = this.session(id);
162
+ if (!s.checkpoint || !["running", "runnable"].includes(s.status))
163
+ return;
164
+ s.status = "running";
165
+ this.store.tx(() => this.store.put("sessions", id, s));
166
+ const controller = new AbortController();
167
+ this.running.set(id, controller);
168
+ try {
169
+ const result = await runDurable({
170
+ manifest: s.manifest,
171
+ checkpoint: s.checkpoint,
172
+ signal: controller.signal,
173
+ host: {
174
+ resolveEffect: (e) => this.resolveEffect(e, controller.signal),
175
+ },
176
+ });
177
+ let event;
178
+ this.store.tx(() => {
179
+ const current = this.session(id);
180
+ if (current.status === "cancelled" ||
181
+ current.activeTurnId !== s.activeTurnId)
182
+ return;
183
+ // A result can arrive during concurrent action persistence. Preserve the runnable marker.
184
+ const resumeRequested = current.status === "runnable";
185
+ if (result.status === "waiting" || result.status === "uncertain") {
186
+ current.status = resumeRequested
187
+ ? "runnable"
188
+ : current.status === "uncertain"
189
+ ? "uncertain"
190
+ : result.status;
191
+ current.waits = { effectIds: result.effectIds };
192
+ }
193
+ else if ("result" in result) {
194
+ current.state =
195
+ result.status === "failed"
196
+ ? current.turnStartState
197
+ : result.result.state;
198
+ current.checkpoint = result.checkpoint;
199
+ this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, result.checkpoint.segment]), { checkpoint: result.checkpoint, status: result.status });
200
+ current.status = result.status;
201
+ current.waits =
202
+ result.status === "paused"
203
+ ? result.result.pending
204
+ : undefined;
205
+ if (result.status !== "paused")
206
+ current.activeTurnId = null;
207
+ event = this.store.event(id, s.activeTurnId, `turn.${result.status}`, result.status === "completed"
208
+ ? { output: result.result.output }
209
+ : result.status === "paused"
210
+ ? {
211
+ interactions: (result.result.pending ?? []).map((call) => ({
212
+ invocationId: call.invocationId,
213
+ interaction: call.interaction,
214
+ wait: call.wait,
215
+ status: call.status,
216
+ })),
217
+ }
218
+ : result.status === "failed"
219
+ ? {
220
+ error: {
221
+ code: result.result.error?.code,
222
+ message: result.result.error?.message,
223
+ },
224
+ }
225
+ : {});
226
+ }
227
+ this.store.put("sessions", id, current);
228
+ });
229
+ if (event)
230
+ this.publish(event);
231
+ }
232
+ catch (error) {
233
+ let event;
234
+ this.store.tx(() => {
235
+ const current = this.session(id);
236
+ if (current.status === "cancelled" ||
237
+ current.activeTurnId !== s.activeTurnId)
238
+ return;
239
+ current.status = "failed";
240
+ current.state = current.turnStartState;
241
+ if (current.checkpoint)
242
+ this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, current.checkpoint.segment]), { checkpoint: current.checkpoint, status: "failed" });
243
+ current.error = error instanceof Error ? error.message : String(error);
244
+ this.store.put("sessions", id, current);
245
+ event = this.store.event(id, current.activeTurnId, "turn.failed", {
246
+ message: current.error,
247
+ });
248
+ current.activeTurnId = null;
249
+ this.store.put("sessions", id, current);
250
+ });
251
+ if (event)
252
+ this.publish(event);
253
+ }
254
+ finally {
255
+ this.running.delete(id);
256
+ if (!this.closing &&
257
+ (this.pending.has(id) || this.session(id).status === "runnable"))
258
+ this.schedule(id);
259
+ }
260
+ }
261
+ async resolveEffect(request, signal) {
262
+ let invoke = false;
263
+ let notify = false;
264
+ let event;
265
+ const resolution = this.store.tx(() => {
266
+ const s = this.session(request.sessionId);
267
+ if (s.status === "cancelled" ||
268
+ s.activeTurnId !== request.turnId ||
269
+ signal.aborted)
270
+ throw new Error("Turn cancelled");
271
+ const existing = this.store.get("effects", request.effectId);
272
+ if (existing) {
273
+ if (canonical(existing.request) !== canonical(request))
274
+ throw new Error("Effect identity request drift");
275
+ return existing.status === "completed"
276
+ ? { status: "completed", outcome: existing.outcome }
277
+ : {
278
+ status: existing.status === "uncertain" ||
279
+ existing.status === "invoking"
280
+ ? "uncertain"
281
+ : "pending",
282
+ };
283
+ }
284
+ this.store.put("effects", request.effectId, {
285
+ request,
286
+ status: request.kind === "model" ? "invoking" : "pending",
287
+ });
288
+ if (request.kind === "model") {
289
+ invoke = true;
290
+ return undefined;
291
+ }
292
+ const action = {
293
+ actionId: request.effectId,
294
+ sessionId: request.sessionId,
295
+ turnId: request.turnId,
296
+ agentId: request.agentId,
297
+ manifestHash: request.manifestHash,
298
+ implementationVersion: s.implementationVersion,
299
+ kind: request.kind,
300
+ capabilityId: request.capabilityId,
301
+ ...(request.toolName ? { toolName: request.toolName } : {}),
302
+ input: request.input,
303
+ context: request.context,
304
+ status: "pending",
305
+ generation: 0,
306
+ claimId: null,
307
+ leaseExpiresAt: null,
308
+ };
309
+ this.store.put("actions", action.actionId, action);
310
+ event = this.store.event(s.id, s.activeTurnId, "action.pending", {
311
+ actionId: action.actionId,
312
+ kind: action.kind,
313
+ toolName: action.toolName,
314
+ input: action.input,
315
+ });
316
+ notify = true;
317
+ return { status: "pending" };
318
+ });
319
+ if (event)
320
+ this.publish(event);
321
+ if (notify)
322
+ this.notify();
323
+ if (!invoke)
324
+ return resolution;
325
+ try {
326
+ const value = await (this.options.model ?? scriptedModel())(request, signal);
327
+ return this.store.tx(() => {
328
+ const s = this.session(request.sessionId);
329
+ if (s.status === "cancelled" ||
330
+ s.activeTurnId !== request.turnId ||
331
+ signal.aborted)
332
+ throw new Error("Turn cancelled");
333
+ const effect = this.store.get("effects", request.effectId);
334
+ effect.status = "completed";
335
+ effect.outcome = { value };
336
+ this.store.put("effects", request.effectId, effect);
337
+ return { status: "completed", outcome: effect.outcome };
338
+ });
339
+ }
340
+ catch (error) {
341
+ let event;
342
+ this.store.tx(() => {
343
+ const effect = this.store.get("effects", request.effectId);
344
+ effect.status = "uncertain";
345
+ effect.error = error instanceof Error ? error.message : String(error);
346
+ this.store.put("effects", request.effectId, effect);
347
+ const s = this.session(request.sessionId);
348
+ if (s.status !== "cancelled" && s.activeTurnId === request.turnId)
349
+ event = this.store.event(s.id, request.turnId, "effect.uncertain", {
350
+ effectId: request.effectId,
351
+ message: effect.error,
352
+ });
353
+ });
354
+ if (event)
355
+ this.publish(event);
356
+ return { status: "uncertain" };
357
+ }
358
+ }
359
+ scope(request) {
360
+ const header = request.headers.authorization;
361
+ const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
362
+ if (token && equals(token, this.options.serverToken))
363
+ return "server";
364
+ const executor = this.options.executors.find((e) => token && equals(token, e.token));
365
+ if (!executor)
366
+ return fail(401, "Invalid credentials");
367
+ return executor;
368
+ }
369
+ scoped(scope, action) {
370
+ if (scope === "server" ||
371
+ scope.agentId !== action.agentId ||
372
+ scope.manifestHash !== action.manifestHash ||
373
+ scope.implementationVersion !== action.implementationVersion)
374
+ fail(403, "Executor scope does not authorize this action");
375
+ }
376
+ async body(request) {
377
+ let data = "";
378
+ for await (const chunk of request) {
379
+ data += chunk;
380
+ if (Buffer.byteLength(data) > 1024 * 1024)
381
+ fail(413, "Request too large");
382
+ }
383
+ try {
384
+ return JSON.parse(data);
385
+ }
386
+ catch {
387
+ return fail(400, "Invalid JSON");
388
+ }
389
+ }
390
+ command(id, command, scope) {
391
+ let event;
392
+ let schedule = false;
393
+ const response = this.store.tx(() => {
394
+ const s = this.session(id);
395
+ const key = JSON.stringify([id, command.idempotencyKey]);
396
+ if (command.type === "action_result") {
397
+ const a = this.store.get("actions", command.actionId) ??
398
+ fail(404, "Action not found");
399
+ if (a.sessionId !== id)
400
+ fail(403, "Action belongs to another session");
401
+ this.scoped(scope, a);
402
+ }
403
+ else if (scope !== "server")
404
+ fail(403, "Application credential required");
405
+ const existing = this.store.get("commands", key);
406
+ if (existing) {
407
+ if (semantic(existing.command) !== semantic(command))
408
+ fail(409, "Idempotency key already binds another command");
409
+ return existing.response;
410
+ }
411
+ if (command.type === "action_result") {
412
+ const action = this.store.get("actions", command.actionId);
413
+ const prior = this.store.get("effects", command.actionId);
414
+ if (action.status === "completed") {
415
+ if (action.claimId !== command.claimId ||
416
+ action.generation !== command.generation ||
417
+ canonical(prior.outcome) !== canonical(command.outcome))
418
+ fail(409, "Conflicting action result");
419
+ this.store.put("commands", key, { command, response: prior.receipt });
420
+ return prior.receipt;
421
+ }
422
+ if (s.status === "cancelled" ||
423
+ s.activeTurnId !== action.turnId ||
424
+ action.status !== "claimed" ||
425
+ action.claimId !== command.claimId ||
426
+ action.generation !== command.generation ||
427
+ Date.parse(action.leaseExpiresAt) <= Date.now())
428
+ fail(409, "Stale, expired, or cancelled claim");
429
+ action.status = "completed";
430
+ this.store.put("actions", action.actionId, action);
431
+ prior.status = "completed";
432
+ prior.outcome = command.outcome;
433
+ s.status = "runnable";
434
+ schedule = true;
435
+ event = this.store.event(id, s.activeTurnId, "action.completed", {
436
+ actionId: action.actionId,
437
+ toolName: action.toolName,
438
+ kind: action.kind,
439
+ result: command.outcome.value,
440
+ });
441
+ const receipt = {
442
+ status: "accepted",
443
+ turnId: s.activeTurnId,
444
+ cursor: event.cursor,
445
+ requestId: command.requestId,
446
+ };
447
+ prior.receipt = receipt;
448
+ this.store.put("effects", action.actionId, prior);
449
+ }
450
+ else if (command.type === "cancel") {
451
+ const cancelledTurnId = s.activeTurnId;
452
+ s.status = "cancelled";
453
+ for (const a of this.store.all("actions"))
454
+ if (a.sessionId === id &&
455
+ a.turnId === cancelledTurnId &&
456
+ ["pending", "claimed"].includes(a.status)) {
457
+ // Claimed work may already have an external effect. Preserve it for reconciliation.
458
+ a.status = a.status === "claimed" ? "uncertain" : "cancelled";
459
+ this.store.put("actions", a.actionId, a);
460
+ const effect = this.store.get("effects", a.actionId);
461
+ effect.status = a.status;
462
+ this.store.put("effects", a.actionId, effect);
463
+ }
464
+ for (const effect of this.store.all("effects"))
465
+ if (effect.request.sessionId === id &&
466
+ effect.request.turnId === cancelledTurnId &&
467
+ effect.status === "invoking") {
468
+ effect.status = "uncertain";
469
+ effect.error =
470
+ "Turn cancelled before model outcome was durably recorded";
471
+ this.store.put("effects", effect.request.effectId, effect);
472
+ }
473
+ event = this.store.event(id, cancelledTurnId, "turn.cancelled", {
474
+ reason: command.reason,
475
+ });
476
+ s.activeTurnId = null;
477
+ // The next turn starts from the state preceding the cancelled turn, never its paused plan.
478
+ if (cancelledTurnId !== null)
479
+ s.state = s.turnStartState;
480
+ s.checkpoint = undefined;
481
+ s.waits = undefined;
482
+ s.error = undefined;
483
+ }
484
+ else {
485
+ if (command.type === "message") {
486
+ if (!["idle", "completed", "failed", "cancelled"].includes(s.status))
487
+ fail(409, "Session has active or unresolved work");
488
+ s.turnStartState = s.state;
489
+ s.activeTurnId = randomUUID();
490
+ s.checkpoint = createDurableCheckpoint({
491
+ manifest: s.manifest,
492
+ sessionId: id,
493
+ turnId: s.activeTurnId,
494
+ input: command.content,
495
+ state: s.state,
496
+ info: s.info,
497
+ });
498
+ }
499
+ else {
500
+ if (s.status !== "paused" || !s.state || !s.activeTurnId)
501
+ fail(409, "Session is not awaiting a response");
502
+ const pending = s.state.plan?.calls ?? [];
503
+ if (!pending.some((c) => c.status === "interaction" &&
504
+ c.interaction?.id === command.interactionId &&
505
+ (command.type === "approve") ===
506
+ (c.interaction?.kind === "approval")))
507
+ fail(409, "Unknown interaction");
508
+ const input = command.type === "approve"
509
+ ? {
510
+ kind: "approve",
511
+ interactionId: command.interactionId,
512
+ approved: command.approved,
513
+ }
514
+ : {
515
+ kind: "respond",
516
+ interactionId: command.interactionId,
517
+ value: command.value,
518
+ };
519
+ s.checkpoint = createDurableCheckpoint({
520
+ manifest: s.manifest,
521
+ sessionId: id,
522
+ turnId: s.activeTurnId,
523
+ input: input,
524
+ state: s.state,
525
+ info: s.info,
526
+ segment: (s.checkpoint?.segment ?? 0) + 1,
527
+ });
528
+ }
529
+ this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, s.checkpoint.segment]), { checkpoint: s.checkpoint, status: "runnable" });
530
+ s.status = "runnable";
531
+ s.waits = undefined;
532
+ schedule = true;
533
+ event = this.store.event(id, s.activeTurnId, `command.${command.type}`, command);
534
+ }
535
+ this.store.put("sessions", id, s);
536
+ const response = {
537
+ status: "accepted",
538
+ turnId: s.activeTurnId,
539
+ cursor: event?.cursor ?? this.store.history(id).cursor,
540
+ requestId: command.requestId,
541
+ };
542
+ this.store.put("commands", key, { command, response });
543
+ return response;
544
+ });
545
+ if (event)
546
+ this.publish(event);
547
+ if (command.type === "cancel")
548
+ this.running.get(id)?.abort();
549
+ if (schedule)
550
+ this.schedule(id);
551
+ return response;
552
+ }
553
+ sse(request, response, set) {
554
+ response.writeHead(200, {
555
+ "content-type": "text/event-stream",
556
+ "cache-control": "no-cache",
557
+ connection: "keep-alive",
558
+ "x-accel-buffering": "no",
559
+ });
560
+ response.flushHeaders();
561
+ set.add(response);
562
+ const timer = setInterval(() => this.send(response, ": keepalive\n\n"), 15000);
563
+ timer.unref();
564
+ request.on("close", () => {
565
+ clearInterval(timer);
566
+ set.delete(response);
567
+ });
568
+ }
569
+ async handle(request, response) {
570
+ const json = (value, status = 200) => {
571
+ response.writeHead(status, { "content-type": "application/json" });
572
+ response.end(JSON.stringify(value));
573
+ };
574
+ try {
575
+ const url = new URL(request.url ?? "/", "http://runtime");
576
+ const path = url.pathname
577
+ .split("/")
578
+ .filter(Boolean)
579
+ .map(decodeURIComponent);
580
+ const method = request.method;
581
+ if (url.pathname === "/health")
582
+ return json({ status: "ok", service: "oss-runtime" });
583
+ if (url.pathname === "/ready") {
584
+ this.store.db.prepare("SELECT 1").get();
585
+ return json({
586
+ status: this.closing ? "not_ready" : "ready",
587
+ service: "oss-runtime",
588
+ checks: {
589
+ sqlite: true,
590
+ scheduler: !this.closing,
591
+ modelConfigured: true,
592
+ },
593
+ }, this.closing ? 503 : 200);
594
+ }
595
+ const scope = this.scope(request);
596
+ if (path[0] !== "v1")
597
+ fail(404, "Route not found");
598
+ if (path[1] === "executors" &&
599
+ path[2] === "connect" &&
600
+ method === "GET") {
601
+ if (scope === "server")
602
+ fail(403, "Executor credential required");
603
+ this.sse(request, response, this.executors);
604
+ this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
605
+ return;
606
+ }
607
+ if (path[1] === "actions") {
608
+ if (scope === "server")
609
+ return fail(403, "Executor credential required");
610
+ this.expireClaims();
611
+ if (path.length === 2 && method === "GET")
612
+ return json({
613
+ actions: this.store
614
+ .all("actions")
615
+ .filter((a) => a.status === "pending" &&
616
+ a.agentId === scope.agentId &&
617
+ a.manifestHash === scope.manifestHash &&
618
+ a.implementationVersion === scope.implementationVersion),
619
+ });
620
+ const actionId = path[2];
621
+ if (!actionId)
622
+ fail(404, "Action not found");
623
+ const body = await this.body(request);
624
+ let event;
625
+ const result = this.store.tx(() => {
626
+ const action = this.store.get("actions", actionId) ??
627
+ fail(404, "Action not found");
628
+ this.scoped(scope, action);
629
+ if (method === "POST" && path[3] === "claim") {
630
+ const claim = ActionClaimRequestSchema.parse(body);
631
+ if (claim.manifestHash !== action.manifestHash ||
632
+ claim.implementationVersion !== action.implementationVersion)
633
+ fail(409, "Definition mismatch");
634
+ if (action.status !== "pending" ||
635
+ this.session(action.sessionId).status === "cancelled" ||
636
+ this.session(action.sessionId).activeTurnId !== action.turnId)
637
+ fail(409, "Action unavailable");
638
+ action.status = "claimed";
639
+ action.generation++;
640
+ action.claimId = randomUUID();
641
+ action.leaseExpiresAt = new Date(Date.now() + (this.options.leaseMs ?? 30000)).toISOString();
642
+ this.store.put("actions", actionId, action);
643
+ event = this.store.event(action.sessionId, action.turnId, "action.claimed", { actionId, generation: action.generation });
644
+ return {
645
+ action,
646
+ claimId: action.claimId,
647
+ generation: action.generation,
648
+ leaseExpiresAt: action.leaseExpiresAt,
649
+ };
650
+ }
651
+ if (method === "POST" && path[3] === "heartbeat") {
652
+ const beat = ActionHeartbeatRequestSchema.parse(body);
653
+ if (this.session(action.sessionId).activeTurnId !== action.turnId ||
654
+ action.status !== "claimed" ||
655
+ action.claimId !== beat.claimId ||
656
+ action.generation !== beat.generation ||
657
+ Date.parse(action.leaseExpiresAt) <= Date.now())
658
+ fail(409, "Stale or expired claim");
659
+ action.leaseExpiresAt = new Date(Date.now() + (this.options.leaseMs ?? 30000)).toISOString();
660
+ this.store.put("actions", actionId, action);
661
+ return { leaseExpiresAt: action.leaseExpiresAt };
662
+ }
663
+ return fail(404, "Route not found");
664
+ });
665
+ if (event)
666
+ this.publish(event);
667
+ return json(result);
668
+ }
669
+ if (path[1] === "sessions" &&
670
+ path[2] &&
671
+ path[3] === "commands" &&
672
+ method === "POST")
673
+ return json(this.command(path[2], SessionCommandSchema.parse(await this.body(request)), scope));
674
+ if (scope !== "server")
675
+ fail(403, "Application credential required");
676
+ if (path[1] === "agents" && path.length === 2 && method === "GET")
677
+ return json({
678
+ agents: this.store
679
+ .all("definitions")
680
+ .map((d) => ({
681
+ agentId: d.manifest.id,
682
+ manifest: d.manifest,
683
+ manifestHash: d.manifestHash,
684
+ implementationVersion: d.implementationVersion,
685
+ })),
686
+ });
687
+ if (path[1] === "sessions" && path.length === 2 && method === "GET")
688
+ return json({
689
+ sessions: this.store
690
+ .all("sessions")
691
+ .filter((s) => !url.searchParams.has("agentId") ||
692
+ s.agentId === url.searchParams.get("agentId"))
693
+ .map((s) => ({
694
+ id: s.id,
695
+ agentId: s.agentId,
696
+ ownerUserId: s.ownerUserId,
697
+ status: s.status,
698
+ activeTurnId: s.activeTurnId,
699
+ })),
700
+ });
701
+ if (path[1] === "agents" &&
702
+ path[2] &&
703
+ path.length === 3 &&
704
+ method === "PUT") {
705
+ const body = PutAgentRequestSchema.parse(await this.body(request));
706
+ if (body.manifest.id !== path[2])
707
+ fail(400, "Agent id mismatch");
708
+ AgentManifestSchema.parse(body.manifest);
709
+ const definition = {
710
+ ...body,
711
+ manifestHash: hashManifest(body.manifest),
712
+ };
713
+ this.store.tx(() => {
714
+ this.store.put("definitions", path[2], definition);
715
+ });
716
+ return json({
717
+ agentId: path[2],
718
+ manifestHash: definition.manifestHash,
719
+ implementationVersion: body.implementationVersion,
720
+ });
721
+ }
722
+ if (path[1] === "sessions" && path[2]) {
723
+ const id = path[2];
724
+ if (method === "PUT" && path.length === 3) {
725
+ const body = PutSessionRequestSchema.parse(await this.body(request));
726
+ const result = this.store.tx(() => {
727
+ const prior = this.store.get("sessions", id);
728
+ if (prior) {
729
+ if (semantic(prior.creation) !== semantic(body))
730
+ fail(409, "Session already exists with different creation parameters");
731
+ return prior;
732
+ }
733
+ const definition = this.store.get("definitions", body.agentId) ??
734
+ fail(404, "Definition not found");
735
+ const s = {
736
+ id,
737
+ agentId: body.agentId,
738
+ ownerUserId: body.ownerUserId,
739
+ manifest: definition.manifest,
740
+ manifestHash: definition.manifestHash,
741
+ implementationVersion: definition.implementationVersion,
742
+ info: body.info,
743
+ status: "idle",
744
+ activeTurnId: null,
745
+ creation: body,
746
+ };
747
+ this.store.put("sessions", id, s);
748
+ return s;
749
+ });
750
+ return json(this.view(result));
751
+ }
752
+ const s = this.session(id);
753
+ if (method === "GET" && path.length === 3)
754
+ return json(this.view(s));
755
+ const cursor = url.searchParams.get("cursor") ??
756
+ (typeof request.headers["last-event-id"] === "string"
757
+ ? request.headers["last-event-id"]
758
+ : undefined);
759
+ if (method === "GET" && path[3] === "items")
760
+ return json(this.store.history(id, cursor));
761
+ if (method === "GET" && path[3] === "events") {
762
+ const history = this.store.history(id, cursor);
763
+ const set = this.observers.get(id) ?? new Set();
764
+ this.observers.set(id, set);
765
+ this.sse(request, response, set);
766
+ for (const event of history.items)
767
+ this.send(response, `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
768
+ return;
769
+ }
770
+ }
771
+ fail(404, "Route not found");
772
+ }
773
+ catch (error) {
774
+ if (response.headersSent) {
775
+ response.end();
776
+ return;
777
+ }
778
+ const status = error instanceof HttpError
779
+ ? error.status
780
+ : error?.name === "ZodError" ||
781
+ error?.message === "Invalid cursor"
782
+ ? 400
783
+ : 500;
784
+ json({
785
+ status: "rejected",
786
+ code: status === 500 ? "internal_error" : "request_rejected",
787
+ message: status === 500
788
+ ? "Runtime request failed"
789
+ : error.message,
790
+ }, status);
791
+ }
792
+ }
793
+ view(s) {
794
+ return {
795
+ id: s.id,
796
+ agentId: s.agentId,
797
+ ownerUserId: s.ownerUserId,
798
+ manifestHash: s.manifestHash,
799
+ implementationVersion: s.implementationVersion,
800
+ status: s.status,
801
+ activeTurnId: s.activeTurnId,
802
+ waits: Array.isArray(s.waits)
803
+ ? s.waits.map((call) => ({
804
+ invocationId: call.invocationId,
805
+ interaction: call.interaction,
806
+ wait: call.wait,
807
+ status: call.status,
808
+ }))
809
+ : s.waits,
810
+ error: s.error,
811
+ actions: this.store
812
+ .all("actions")
813
+ .filter((a) => a.sessionId === s.id &&
814
+ !["completed", "cancelled"].includes(a.status)),
815
+ uncertainEffects: this.store
816
+ .all("effects")
817
+ .filter((e) => e.request.sessionId === s.id && e.status === "uncertain")
818
+ .map((e) => ({
819
+ effectId: e.request.effectId,
820
+ turnId: e.request.turnId,
821
+ kind: e.request.kind,
822
+ error: e.error,
823
+ })),
824
+ };
825
+ }
826
+ async listen(port = 8787, hostname = "127.0.0.1") {
827
+ if (this.server)
828
+ throw new Error("Already listening");
829
+ this.server = createServer((q, s) => {
830
+ void this.handle(q, s);
831
+ });
832
+ await new Promise((resolve, reject) => {
833
+ this.server.once("error", reject);
834
+ this.server.listen(port, hostname, resolve);
835
+ });
836
+ const address = this.server.address();
837
+ return {
838
+ url: `http://${hostname}:${typeof address === "object" && address ? address.port : port}`,
839
+ };
840
+ }
841
+ async close() {
842
+ this.closing = true;
843
+ clearInterval(this.timer);
844
+ for (const c of this.running.values())
845
+ c.abort();
846
+ for (const r of this.executors)
847
+ r.end();
848
+ for (const set of this.observers.values())
849
+ for (const r of set)
850
+ r.end();
851
+ if (this.server)
852
+ await new Promise((resolve) => {
853
+ this.server.close(() => resolve());
854
+ this.server.closeIdleConnections();
855
+ });
856
+ // Providers must honor AbortSignal. Keep storage open until any in-flight journal writes have drained.
857
+ while (this.running.size)
858
+ await new Promise((resolve) => setTimeout(resolve, 10));
859
+ this.store.db.close();
860
+ if (this.lockPath)
861
+ unlinkSync(this.lockPath);
862
+ }
863
+ }
864
+ export function createRuntime(options) {
865
+ return new CoreRuntime(options);
866
+ }
867
+ export async function startRuntime(options) {
868
+ const runtime = createRuntime(options);
869
+ try {
870
+ const { url } = await runtime.listen(options.port, options.hostname);
871
+ return Object.assign(runtime, { url });
872
+ }
873
+ catch (error) {
874
+ await runtime.close();
875
+ throw error;
876
+ }
877
+ }