@nylorun/runtime 0.5.0-beta → 0.7.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.
- package/CHANGELOG.md +45 -0
- package/README.md +30 -60
- package/dist/config.d.ts +1 -1
- package/dist/configuration.d.ts +3 -0
- package/dist/configuration.js +3 -0
- package/dist/contracts.d.ts +4 -2
- package/dist/core/main.js +40 -0
- package/dist/core/provider.d.ts +12 -0
- package/dist/core/provider.js +60 -0
- package/dist/core/runtime.d.ts +67 -0
- package/dist/core/runtime.js +1126 -0
- package/dist/core/store.d.ts +17 -0
- package/dist/core/store.js +146 -0
- package/dist/index.d.ts +6 -11
- package/dist/index.js +4 -6
- package/dist/mcp/connect.d.ts +31 -0
- package/dist/mcp/connect.js +190 -0
- package/dist/mcp/pool.d.ts +45 -0
- package/dist/mcp/pool.js +143 -0
- package/dist/mcp/snapshot.d.ts +25 -0
- package/dist/mcp/snapshot.js +10 -0
- package/dist/mcp/stdio.d.ts +15 -0
- package/dist/mcp/stdio.js +80 -0
- package/dist/model/defaults.d.ts +1 -1
- package/dist/model/http-model.d.ts +1 -1
- package/dist/node/local-sessions.js +17 -0
- package/dist/server/host.d.ts +42 -4
- package/dist/server/host.js +69 -3
- package/dist/session/api.d.ts +10 -0
- package/dist/session/api.js +15 -0
- package/dist/session/default.d.ts +5 -0
- package/dist/session/default.js +30 -0
- package/dist/session/handle.d.ts +27 -0
- package/dist/session/handle.js +199 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/sessions/host.d.ts +11 -1
- package/dist/sessions/host.js +75 -7
- package/dist/sessions/store.d.ts +1 -0
- package/dist/sessions/store.js +3 -0
- package/dist/vault/crypto.d.ts +12 -0
- package/dist/vault/crypto.js +80 -0
- package/dist/vault/error.d.ts +4 -0
- package/dist/vault/error.js +7 -0
- package/dist/vault/kek.d.ts +6 -0
- package/dist/vault/kek.js +26 -0
- package/dist/vault/service.d.ts +59 -0
- package/dist/vault/service.js +538 -0
- package/dist/vault/url.d.ts +3 -0
- package/dist/vault/url.js +27 -0
- package/package.json +19 -13
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -100
- package/dist/dev-entry.js +0 -2
- package/dist/dev.d.ts +0 -2
- package/dist/dev.js +0 -126
- package/dist/environment.d.ts +0 -2
- package/dist/environment.js +0 -64
- package/dist/launcher.d.ts +0 -1
- package/dist/launcher.js +0 -33
- package/dist/model/configure.d.ts +0 -12
- package/dist/model/configure.js +0 -155
- /package/dist/{dev-entry.d.ts → core/main.d.ts} +0 -0
|
@@ -0,0 +1,1126 @@
|
|
|
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, CreateCredentialRequestSchema, CreateVaultRequestSchema, PutAgentRequestSchema, PutSessionRequestSchema, RotateCredentialRequestSchema, SessionCommandSchema, ActionClaimRequestSchema, ActionHeartbeatRequestSchema, } from "@nylorun/core/contracts";
|
|
6
|
+
import { createDurableCheckpoint, runDurable, } from "@nylorun/harness/run";
|
|
7
|
+
import { hashManifest } from "@nylorun/core/compatibility";
|
|
8
|
+
import { schemaFromJSON, } from "@nylorun/core/define";
|
|
9
|
+
import { Store, canonical } from "./store.js";
|
|
10
|
+
import { scriptedModel } from "./provider.js";
|
|
11
|
+
import { scrub } from "../redact.js";
|
|
12
|
+
import { createKekFile, defaultKekPath, readVaultKek } from "../vault/kek.js";
|
|
13
|
+
import { VaultError } from "../vault/error.js";
|
|
14
|
+
import { VaultService } from "../vault/service.js";
|
|
15
|
+
import { McpPool } from "../mcp/pool.js";
|
|
16
|
+
class HttpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
constructor(status, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const fail = (status, message) => {
|
|
24
|
+
throw new HttpError(status, message);
|
|
25
|
+
};
|
|
26
|
+
function sessionToolsOf(snapshot) {
|
|
27
|
+
if (!snapshot?.mcpTools.length)
|
|
28
|
+
return undefined;
|
|
29
|
+
return snapshot.mcpTools.map((tool) => ({
|
|
30
|
+
capabilityId: tool.capabilityId,
|
|
31
|
+
name: tool.name,
|
|
32
|
+
...(tool.description === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { description: tool.description }),
|
|
35
|
+
inputSchema: tool.inputSchema,
|
|
36
|
+
...(tool.outputSchema === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { outputSchema: tool.outputSchema }),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function mcpToolOf(session, capabilityId, toolName) {
|
|
42
|
+
return session.mcpSnapshot?.mcpTools.find((tool) => tool.capabilityId === capabilityId && tool.name === toolName);
|
|
43
|
+
}
|
|
44
|
+
function pinnedTool(manifest, capabilityId, toolName) {
|
|
45
|
+
const capability = manifest.capabilities.find((item) => item.id === capabilityId);
|
|
46
|
+
return capability?.tools?.find((tool) => tool.name === toolName);
|
|
47
|
+
}
|
|
48
|
+
function acceptedToolResult(action, command) {
|
|
49
|
+
if (action.kind !== "tool" || !action.outputSchema)
|
|
50
|
+
return command;
|
|
51
|
+
const value = command.outcome.value;
|
|
52
|
+
if (isFailedToolValue(value))
|
|
53
|
+
return command;
|
|
54
|
+
// Executors wrap successful tool output as `{ kind: "completed", output }`.
|
|
55
|
+
// Validate the tool payload, not the outcome envelope.
|
|
56
|
+
const candidate = completedToolOutput(value);
|
|
57
|
+
let matches = false;
|
|
58
|
+
try {
|
|
59
|
+
matches = schemaFromJSON(action.outputSchema).validate(candidate).ok;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
matches = false;
|
|
63
|
+
}
|
|
64
|
+
if (matches)
|
|
65
|
+
return command;
|
|
66
|
+
return {
|
|
67
|
+
...command,
|
|
68
|
+
outcome: {
|
|
69
|
+
...command.outcome,
|
|
70
|
+
value: {
|
|
71
|
+
kind: "failed",
|
|
72
|
+
code: "tool.invalid-output",
|
|
73
|
+
message: "Tool result does not match the output schema stored on the action",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function completedToolOutput(value) {
|
|
79
|
+
if (value &&
|
|
80
|
+
typeof value === "object" &&
|
|
81
|
+
!Array.isArray(value) &&
|
|
82
|
+
value.kind === "completed" &&
|
|
83
|
+
"output" in value) {
|
|
84
|
+
return value.output;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function isFailedToolValue(value) {
|
|
89
|
+
return (!!value &&
|
|
90
|
+
typeof value === "object" &&
|
|
91
|
+
!Array.isArray(value) &&
|
|
92
|
+
value.kind === "failed");
|
|
93
|
+
}
|
|
94
|
+
const semantic = (value) => {
|
|
95
|
+
const { requestId: _, ...body } = value;
|
|
96
|
+
return canonical(body);
|
|
97
|
+
};
|
|
98
|
+
const sessionIdentity = (value) => {
|
|
99
|
+
const { requestId: _requestId, vaultIds: _vaultIds, credentialSelections: _selections, ...body } = value ?? {};
|
|
100
|
+
return canonical(body);
|
|
101
|
+
};
|
|
102
|
+
const equals = (a, b) => {
|
|
103
|
+
const aa = Buffer.from(a), bb = Buffer.from(b);
|
|
104
|
+
return aa.length === bb.length && timingSafeEqual(aa, bb);
|
|
105
|
+
};
|
|
106
|
+
export class CoreRuntime {
|
|
107
|
+
options;
|
|
108
|
+
store;
|
|
109
|
+
vault;
|
|
110
|
+
kek;
|
|
111
|
+
kekPath;
|
|
112
|
+
running = new Map();
|
|
113
|
+
pending = new Set();
|
|
114
|
+
observers = new Map();
|
|
115
|
+
executors = new Set();
|
|
116
|
+
mcp;
|
|
117
|
+
server;
|
|
118
|
+
closing = false;
|
|
119
|
+
lockPath;
|
|
120
|
+
timer;
|
|
121
|
+
constructor(options) {
|
|
122
|
+
this.options = options;
|
|
123
|
+
if (options.leaseMs !== undefined &&
|
|
124
|
+
(!Number.isFinite(options.leaseMs) || options.leaseMs <= 0))
|
|
125
|
+
throw new Error("leaseMs must be finite and positive");
|
|
126
|
+
if (new Set(options.executors.map((e) => e.token)).size !==
|
|
127
|
+
options.executors.length)
|
|
128
|
+
throw new Error("Executor tokens must be unique");
|
|
129
|
+
if (!options.serverToken || options.serverToken.length < 16)
|
|
130
|
+
throw new Error("A server token of at least 16 characters is required");
|
|
131
|
+
if (options.executors.some((e) => e.token.length < 16 ||
|
|
132
|
+
equals(e.token, options.serverToken) ||
|
|
133
|
+
!e.agentId ||
|
|
134
|
+
!e.implementationVersion))
|
|
135
|
+
throw new Error("Executor tokens require independent credentials and an agent id");
|
|
136
|
+
if (options.sqlitePath !== ":memory:") {
|
|
137
|
+
mkdirSync(dirname(options.sqlitePath), { recursive: true });
|
|
138
|
+
this.lockPath = options.sqlitePath + ".runtime-lock";
|
|
139
|
+
// One local scheduling owner; recover a stale process lock only after checking that its PID is absent.
|
|
140
|
+
try {
|
|
141
|
+
const oldPid = Number(readFileSync(this.lockPath, "utf8"));
|
|
142
|
+
if (!Number.isSafeInteger(oldPid) || oldPid < 1)
|
|
143
|
+
throw new Error("Invalid runtime lock; inspect before removing");
|
|
144
|
+
try {
|
|
145
|
+
process.kill(oldPid, 0);
|
|
146
|
+
throw new Error("Another Runtime owns this SQLite database");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code !== "ESRCH")
|
|
150
|
+
throw error;
|
|
151
|
+
unlinkSync(this.lockPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code !== "ENOENT")
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
const fd = openSync(this.lockPath, "wx");
|
|
159
|
+
writeFileSync(fd, String(process.pid));
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
this.kekPath = options.vaultKekPath ?? defaultKekPath();
|
|
163
|
+
let store;
|
|
164
|
+
try {
|
|
165
|
+
store = new Store(options.sqlitePath);
|
|
166
|
+
this.kek = readVaultKek({
|
|
167
|
+
vaultKek: options.vaultKek,
|
|
168
|
+
vaultKekPath: this.kekPath,
|
|
169
|
+
});
|
|
170
|
+
if (store.credentialCount() > 0 && !this.kek)
|
|
171
|
+
throw new Error("Vault key-encryption key is required to open this database");
|
|
172
|
+
this.store = store;
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
store?.db.close();
|
|
176
|
+
if (this.lockPath)
|
|
177
|
+
unlinkSync(this.lockPath);
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
this.vault = new VaultService(this.store.db, (fn) => this.store.tx(fn), () => this.ensureKek(), options.vaultFetch ?? globalThis.fetch);
|
|
181
|
+
this.mcp = new McpPool({
|
|
182
|
+
dataDir: dirname(options.sqlitePath),
|
|
183
|
+
authorize: (sessionId, request) => this.authorize(sessionId, request),
|
|
184
|
+
});
|
|
185
|
+
this.store.tx(() => {
|
|
186
|
+
for (const effect of this.store.all("effects"))
|
|
187
|
+
if (effect.status === "invoking") {
|
|
188
|
+
effect.status = "uncertain";
|
|
189
|
+
this.store.put("effects", effect.request.effectId, effect);
|
|
190
|
+
const s = this.store.get("sessions", effect.request.sessionId);
|
|
191
|
+
if (s &&
|
|
192
|
+
s.status !== "cancelled" &&
|
|
193
|
+
s.activeTurnId === effect.request.turnId) {
|
|
194
|
+
s.status = "uncertain";
|
|
195
|
+
this.store.put("sessions", s.id, s);
|
|
196
|
+
this.store.event(s.id, s.activeTurnId, "effect.uncertain", {
|
|
197
|
+
effectId: effect.request.effectId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
this.expireClaims();
|
|
203
|
+
this.timer = setInterval(() => this.expireClaims(), Math.min(options.leaseMs ?? 30000, 5000));
|
|
204
|
+
this.timer.unref();
|
|
205
|
+
for (const s of this.store.all("sessions"))
|
|
206
|
+
if (s.status === "running" || s.status === "runnable")
|
|
207
|
+
this.schedule(s.id);
|
|
208
|
+
}
|
|
209
|
+
session(id) {
|
|
210
|
+
return (this.store.get("sessions", id) ?? fail(404, "Session not found"));
|
|
211
|
+
}
|
|
212
|
+
publish(event) {
|
|
213
|
+
for (const response of this.observers.get(event.sessionId) ?? [])
|
|
214
|
+
this.send(response, `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
215
|
+
}
|
|
216
|
+
send(response, data) {
|
|
217
|
+
if (!response.write(data))
|
|
218
|
+
response.destroy();
|
|
219
|
+
}
|
|
220
|
+
notify() {
|
|
221
|
+
for (const response of this.executors)
|
|
222
|
+
this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
|
|
223
|
+
}
|
|
224
|
+
expireClaims() {
|
|
225
|
+
const events = [];
|
|
226
|
+
this.store.tx(() => {
|
|
227
|
+
for (const action of this.store.all("actions"))
|
|
228
|
+
if (action.status === "claimed" &&
|
|
229
|
+
Date.parse(action.leaseExpiresAt) <= Date.now()) {
|
|
230
|
+
action.status = "uncertain";
|
|
231
|
+
this.store.put("actions", action.actionId, action);
|
|
232
|
+
const effect = this.store.get("effects", action.actionId);
|
|
233
|
+
effect.status = "uncertain";
|
|
234
|
+
this.store.put("effects", action.actionId, effect);
|
|
235
|
+
const s = this.session(action.sessionId);
|
|
236
|
+
if (s.status !== "cancelled" && s.activeTurnId === action.turnId) {
|
|
237
|
+
s.status = "uncertain";
|
|
238
|
+
this.store.put("sessions", s.id, s);
|
|
239
|
+
events.push(this.store.event(s.id, s.activeTurnId, "action.uncertain", {
|
|
240
|
+
actionId: action.actionId,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
events.forEach((e) => this.publish(e));
|
|
246
|
+
}
|
|
247
|
+
schedule(id) {
|
|
248
|
+
if (this.closing)
|
|
249
|
+
return;
|
|
250
|
+
this.pending.add(id);
|
|
251
|
+
setImmediate(() => {
|
|
252
|
+
if (!this.running.has(id) && this.pending.delete(id) && !this.closing)
|
|
253
|
+
void this.execute(id);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async execute(id) {
|
|
257
|
+
const s = this.session(id);
|
|
258
|
+
if (!s.checkpoint || !["running", "runnable"].includes(s.status))
|
|
259
|
+
return;
|
|
260
|
+
s.status = "running";
|
|
261
|
+
this.store.tx(() => this.store.put("sessions", id, s));
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
this.running.set(id, controller);
|
|
264
|
+
try {
|
|
265
|
+
await this.prepareMcp(id, controller.signal);
|
|
266
|
+
const current = this.session(id);
|
|
267
|
+
const result = await runDurable({
|
|
268
|
+
manifest: current.manifest,
|
|
269
|
+
checkpoint: current.checkpoint,
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
sessionTools: sessionToolsOf(current.mcpSnapshot),
|
|
272
|
+
host: {
|
|
273
|
+
resolveEffect: (e) => this.resolveEffect(e, controller.signal),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
let event;
|
|
277
|
+
this.store.tx(() => {
|
|
278
|
+
const current = this.session(id);
|
|
279
|
+
if (current.status === "cancelled" ||
|
|
280
|
+
current.activeTurnId !== s.activeTurnId)
|
|
281
|
+
return;
|
|
282
|
+
// A result can arrive during concurrent action persistence. Preserve the runnable marker.
|
|
283
|
+
const resumeRequested = current.status === "runnable";
|
|
284
|
+
if (result.status === "waiting" || result.status === "uncertain") {
|
|
285
|
+
current.status = resumeRequested
|
|
286
|
+
? "runnable"
|
|
287
|
+
: current.status === "uncertain"
|
|
288
|
+
? "uncertain"
|
|
289
|
+
: result.status;
|
|
290
|
+
current.waits = { effectIds: result.effectIds };
|
|
291
|
+
}
|
|
292
|
+
else if ("result" in result) {
|
|
293
|
+
current.state =
|
|
294
|
+
result.status === "failed"
|
|
295
|
+
? current.turnStartState
|
|
296
|
+
: result.result.state;
|
|
297
|
+
current.checkpoint = result.checkpoint;
|
|
298
|
+
this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, result.checkpoint.segment]), { checkpoint: result.checkpoint, status: result.status });
|
|
299
|
+
current.status = result.status;
|
|
300
|
+
current.waits =
|
|
301
|
+
result.status === "paused"
|
|
302
|
+
? result.result.pending
|
|
303
|
+
: undefined;
|
|
304
|
+
if (result.status !== "paused")
|
|
305
|
+
current.activeTurnId = null;
|
|
306
|
+
event = this.store.event(id, s.activeTurnId, `turn.${result.status}`, result.status === "completed"
|
|
307
|
+
? { output: result.result.output }
|
|
308
|
+
: result.status === "paused"
|
|
309
|
+
? {
|
|
310
|
+
interactions: (result.result.pending ?? []).map((call) => ({
|
|
311
|
+
invocationId: call.invocationId,
|
|
312
|
+
interaction: call.interaction,
|
|
313
|
+
wait: call.wait,
|
|
314
|
+
status: call.status,
|
|
315
|
+
})),
|
|
316
|
+
}
|
|
317
|
+
: result.status === "failed"
|
|
318
|
+
? {
|
|
319
|
+
error: {
|
|
320
|
+
code: result.result.error?.code,
|
|
321
|
+
message: result.result.error?.message,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
: {});
|
|
325
|
+
}
|
|
326
|
+
this.store.put("sessions", id, current);
|
|
327
|
+
});
|
|
328
|
+
if (event)
|
|
329
|
+
this.publish(event);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
let event;
|
|
333
|
+
this.store.tx(() => {
|
|
334
|
+
const current = this.session(id);
|
|
335
|
+
if (current.status === "cancelled" ||
|
|
336
|
+
current.activeTurnId !== s.activeTurnId)
|
|
337
|
+
return;
|
|
338
|
+
current.status = "failed";
|
|
339
|
+
current.state = current.turnStartState;
|
|
340
|
+
if (current.checkpoint)
|
|
341
|
+
this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, current.checkpoint.segment]), { checkpoint: current.checkpoint, status: "failed" });
|
|
342
|
+
current.error = error instanceof Error ? error.message : String(error);
|
|
343
|
+
this.store.put("sessions", id, current);
|
|
344
|
+
event = this.store.event(id, current.activeTurnId, "turn.failed", {
|
|
345
|
+
message: current.error,
|
|
346
|
+
});
|
|
347
|
+
current.activeTurnId = null;
|
|
348
|
+
this.store.put("sessions", id, current);
|
|
349
|
+
});
|
|
350
|
+
if (event)
|
|
351
|
+
this.publish(event);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
this.running.delete(id);
|
|
355
|
+
if (!this.closing &&
|
|
356
|
+
(this.pending.has(id) || this.session(id).status === "runnable"))
|
|
357
|
+
this.schedule(id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async resolveEffect(request, signal) {
|
|
361
|
+
let invoke;
|
|
362
|
+
let notify = false;
|
|
363
|
+
let event;
|
|
364
|
+
const resolution = this.store.tx(() => {
|
|
365
|
+
const s = this.session(request.sessionId);
|
|
366
|
+
if (s.status === "cancelled" ||
|
|
367
|
+
s.activeTurnId !== request.turnId ||
|
|
368
|
+
signal.aborted)
|
|
369
|
+
throw new Error("Turn cancelled");
|
|
370
|
+
const existing = this.store.get("effects", request.effectId);
|
|
371
|
+
if (existing) {
|
|
372
|
+
if (canonical(existing.request) !== canonical(request))
|
|
373
|
+
throw new Error("Effect identity request drift");
|
|
374
|
+
return existing.status === "completed"
|
|
375
|
+
? { status: "completed", outcome: existing.outcome }
|
|
376
|
+
: {
|
|
377
|
+
status: existing.status === "uncertain" ||
|
|
378
|
+
existing.status === "invoking"
|
|
379
|
+
? "uncertain"
|
|
380
|
+
: "pending",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const mcpTool = request.kind === "tool"
|
|
384
|
+
? mcpToolOf(s, request.capabilityId, request.toolName)
|
|
385
|
+
: undefined;
|
|
386
|
+
this.store.put("effects", request.effectId, {
|
|
387
|
+
request,
|
|
388
|
+
status: request.kind === "model" || mcpTool ? "invoking" : "pending",
|
|
389
|
+
});
|
|
390
|
+
if (request.kind === "model" || mcpTool) {
|
|
391
|
+
invoke = mcpTool ? "mcp" : "model";
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
const tool = request.kind === "tool"
|
|
395
|
+
? pinnedTool(s.manifest, request.capabilityId, request.toolName)
|
|
396
|
+
: undefined;
|
|
397
|
+
const action = {
|
|
398
|
+
actionId: request.effectId,
|
|
399
|
+
sessionId: request.sessionId,
|
|
400
|
+
turnId: request.turnId,
|
|
401
|
+
agentId: request.agentId,
|
|
402
|
+
manifestHash: request.manifestHash,
|
|
403
|
+
implementationVersion: s.implementationVersion,
|
|
404
|
+
kind: request.kind,
|
|
405
|
+
capabilityId: request.capabilityId,
|
|
406
|
+
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
407
|
+
...(tool?.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
408
|
+
...(tool?.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
409
|
+
input: request.input,
|
|
410
|
+
context: request.context,
|
|
411
|
+
status: "pending",
|
|
412
|
+
generation: 0,
|
|
413
|
+
claimId: null,
|
|
414
|
+
leaseExpiresAt: null,
|
|
415
|
+
};
|
|
416
|
+
this.store.put("actions", action.actionId, action);
|
|
417
|
+
event = this.store.event(s.id, s.activeTurnId, "action.pending", {
|
|
418
|
+
actionId: action.actionId,
|
|
419
|
+
kind: action.kind,
|
|
420
|
+
toolName: action.toolName,
|
|
421
|
+
input: action.input,
|
|
422
|
+
});
|
|
423
|
+
notify = true;
|
|
424
|
+
return { status: "pending" };
|
|
425
|
+
});
|
|
426
|
+
if (event)
|
|
427
|
+
this.publish(event);
|
|
428
|
+
if (notify)
|
|
429
|
+
this.notify();
|
|
430
|
+
if (!invoke)
|
|
431
|
+
return resolution;
|
|
432
|
+
try {
|
|
433
|
+
const value = invoke === "mcp"
|
|
434
|
+
? await this.callMcpTool(request)
|
|
435
|
+
: await (this.options.model ?? scriptedModel())(request, signal);
|
|
436
|
+
return this.store.tx(() => {
|
|
437
|
+
const s = this.session(request.sessionId);
|
|
438
|
+
if (s.status === "cancelled" ||
|
|
439
|
+
s.activeTurnId !== request.turnId ||
|
|
440
|
+
signal.aborted)
|
|
441
|
+
throw new Error("Turn cancelled");
|
|
442
|
+
const effect = this.store.get("effects", request.effectId);
|
|
443
|
+
effect.status = "completed";
|
|
444
|
+
effect.outcome = { value };
|
|
445
|
+
this.store.put("effects", request.effectId, effect);
|
|
446
|
+
return { status: "completed", outcome: effect.outcome };
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
let event;
|
|
451
|
+
this.store.tx(() => {
|
|
452
|
+
const effect = this.store.get("effects", request.effectId);
|
|
453
|
+
effect.status = "uncertain";
|
|
454
|
+
effect.error = error instanceof Error ? error.message : String(error);
|
|
455
|
+
this.store.put("effects", request.effectId, effect);
|
|
456
|
+
const s = this.session(request.sessionId);
|
|
457
|
+
if (s.status !== "cancelled" && s.activeTurnId === request.turnId)
|
|
458
|
+
event = this.store.event(s.id, request.turnId, "effect.uncertain", {
|
|
459
|
+
effectId: request.effectId,
|
|
460
|
+
message: effect.error,
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
if (event)
|
|
464
|
+
this.publish(event);
|
|
465
|
+
return { status: "uncertain" };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
scope(request) {
|
|
469
|
+
const header = request.headers.authorization;
|
|
470
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
|
|
471
|
+
if (token && equals(token, this.options.serverToken))
|
|
472
|
+
return "server";
|
|
473
|
+
const executor = this.options.executors.find((e) => token && equals(token, e.token));
|
|
474
|
+
if (!executor)
|
|
475
|
+
return fail(401, "Invalid credentials");
|
|
476
|
+
return executor;
|
|
477
|
+
}
|
|
478
|
+
scoped(scope, action) {
|
|
479
|
+
if (scope === "server" || scope.agentId !== action.agentId)
|
|
480
|
+
fail(403, "Executor scope does not authorize this action");
|
|
481
|
+
}
|
|
482
|
+
async body(request) {
|
|
483
|
+
let data = "";
|
|
484
|
+
for await (const chunk of request) {
|
|
485
|
+
data += chunk;
|
|
486
|
+
if (Buffer.byteLength(data) > 1024 * 1024)
|
|
487
|
+
fail(413, "Request too large");
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
return JSON.parse(data);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return fail(400, "Invalid JSON");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
command(id, input, scope) {
|
|
497
|
+
let event;
|
|
498
|
+
let schedule = false;
|
|
499
|
+
const response = this.store.tx(() => {
|
|
500
|
+
const s = this.session(id);
|
|
501
|
+
let command = input;
|
|
502
|
+
if (command.type === "action_result") {
|
|
503
|
+
const a = this.store.get("actions", command.actionId) ??
|
|
504
|
+
fail(404, "Action not found");
|
|
505
|
+
if (a.sessionId !== id)
|
|
506
|
+
fail(403, "Action belongs to another session");
|
|
507
|
+
this.scoped(scope, a);
|
|
508
|
+
command = acceptedToolResult(a, command);
|
|
509
|
+
}
|
|
510
|
+
else if (scope !== "server")
|
|
511
|
+
fail(403, "Application credential required");
|
|
512
|
+
const key = JSON.stringify([id, command.idempotencyKey]);
|
|
513
|
+
const existing = this.store.get("commands", key);
|
|
514
|
+
if (existing) {
|
|
515
|
+
if (semantic(existing.command) !== semantic(command))
|
|
516
|
+
fail(409, "Idempotency key already binds another command");
|
|
517
|
+
return existing.response;
|
|
518
|
+
}
|
|
519
|
+
if (command.type === "action_result") {
|
|
520
|
+
const action = this.store.get("actions", command.actionId);
|
|
521
|
+
const prior = this.store.get("effects", command.actionId);
|
|
522
|
+
if (action.status === "completed") {
|
|
523
|
+
if (action.claimId !== command.claimId ||
|
|
524
|
+
action.generation !== command.generation ||
|
|
525
|
+
canonical(prior.outcome) !== canonical(command.outcome))
|
|
526
|
+
fail(409, "Conflicting action result");
|
|
527
|
+
this.store.put("commands", key, { command, response: prior.receipt });
|
|
528
|
+
return prior.receipt;
|
|
529
|
+
}
|
|
530
|
+
if (s.status === "cancelled" ||
|
|
531
|
+
s.activeTurnId !== action.turnId ||
|
|
532
|
+
action.status !== "claimed" ||
|
|
533
|
+
action.claimId !== command.claimId ||
|
|
534
|
+
action.generation !== command.generation ||
|
|
535
|
+
Date.parse(action.leaseExpiresAt) <= Date.now())
|
|
536
|
+
fail(409, "Stale, expired, or cancelled claim");
|
|
537
|
+
action.status = "completed";
|
|
538
|
+
this.store.put("actions", action.actionId, action);
|
|
539
|
+
prior.status = "completed";
|
|
540
|
+
prior.outcome = command.outcome;
|
|
541
|
+
s.status = "runnable";
|
|
542
|
+
schedule = true;
|
|
543
|
+
event = this.store.event(id, s.activeTurnId, "action.completed", {
|
|
544
|
+
actionId: action.actionId,
|
|
545
|
+
toolName: action.toolName,
|
|
546
|
+
kind: action.kind,
|
|
547
|
+
result: command.outcome.value,
|
|
548
|
+
});
|
|
549
|
+
const receipt = {
|
|
550
|
+
status: "accepted",
|
|
551
|
+
turnId: s.activeTurnId,
|
|
552
|
+
cursor: event.cursor,
|
|
553
|
+
requestId: command.requestId,
|
|
554
|
+
};
|
|
555
|
+
prior.receipt = receipt;
|
|
556
|
+
this.store.put("effects", action.actionId, prior);
|
|
557
|
+
}
|
|
558
|
+
else if (command.type === "cancel") {
|
|
559
|
+
const cancelledTurnId = s.activeTurnId;
|
|
560
|
+
s.status = "cancelled";
|
|
561
|
+
for (const a of this.store.all("actions"))
|
|
562
|
+
if (a.sessionId === id &&
|
|
563
|
+
a.turnId === cancelledTurnId &&
|
|
564
|
+
["pending", "claimed"].includes(a.status)) {
|
|
565
|
+
// Claimed work may already have an external effect. Preserve it for reconciliation.
|
|
566
|
+
a.status = a.status === "claimed" ? "uncertain" : "cancelled";
|
|
567
|
+
this.store.put("actions", a.actionId, a);
|
|
568
|
+
const effect = this.store.get("effects", a.actionId);
|
|
569
|
+
effect.status = a.status;
|
|
570
|
+
this.store.put("effects", a.actionId, effect);
|
|
571
|
+
}
|
|
572
|
+
for (const effect of this.store.all("effects"))
|
|
573
|
+
if (effect.request.sessionId === id &&
|
|
574
|
+
effect.request.turnId === cancelledTurnId &&
|
|
575
|
+
effect.status === "invoking") {
|
|
576
|
+
effect.status = "uncertain";
|
|
577
|
+
effect.error =
|
|
578
|
+
"Turn cancelled before model outcome was durably recorded";
|
|
579
|
+
this.store.put("effects", effect.request.effectId, effect);
|
|
580
|
+
}
|
|
581
|
+
event = this.store.event(id, cancelledTurnId, "turn.cancelled", {
|
|
582
|
+
reason: command.reason,
|
|
583
|
+
});
|
|
584
|
+
s.activeTurnId = null;
|
|
585
|
+
// The next turn starts from the state preceding the cancelled turn, never its paused plan.
|
|
586
|
+
if (cancelledTurnId !== null)
|
|
587
|
+
s.state = s.turnStartState;
|
|
588
|
+
s.checkpoint = undefined;
|
|
589
|
+
s.waits = undefined;
|
|
590
|
+
s.error = undefined;
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
if (command.type === "message") {
|
|
594
|
+
if (!["idle", "completed", "failed", "cancelled"].includes(s.status))
|
|
595
|
+
fail(409, "Session has active or unresolved work");
|
|
596
|
+
s.turnStartState = s.state;
|
|
597
|
+
s.activeTurnId = randomUUID();
|
|
598
|
+
s.checkpoint = createDurableCheckpoint({
|
|
599
|
+
manifest: s.manifest,
|
|
600
|
+
sessionId: id,
|
|
601
|
+
turnId: s.activeTurnId,
|
|
602
|
+
input: command.content,
|
|
603
|
+
state: s.state,
|
|
604
|
+
info: s.info,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
if (s.status !== "paused" || !s.state || !s.activeTurnId)
|
|
609
|
+
fail(409, "Session is not awaiting a response");
|
|
610
|
+
const pending = s.state.plan?.calls ?? [];
|
|
611
|
+
if (!pending.some((c) => c.status === "interaction" &&
|
|
612
|
+
c.interaction?.id === command.interactionId &&
|
|
613
|
+
(command.type === "approve") ===
|
|
614
|
+
(c.interaction?.kind === "approval")))
|
|
615
|
+
fail(409, "Unknown interaction");
|
|
616
|
+
const input = command.type === "approve"
|
|
617
|
+
? {
|
|
618
|
+
kind: "approve",
|
|
619
|
+
interactionId: command.interactionId,
|
|
620
|
+
approved: command.approved,
|
|
621
|
+
}
|
|
622
|
+
: {
|
|
623
|
+
kind: "respond",
|
|
624
|
+
interactionId: command.interactionId,
|
|
625
|
+
value: command.value,
|
|
626
|
+
};
|
|
627
|
+
s.checkpoint = createDurableCheckpoint({
|
|
628
|
+
manifest: s.manifest,
|
|
629
|
+
sessionId: id,
|
|
630
|
+
turnId: s.activeTurnId,
|
|
631
|
+
input: input,
|
|
632
|
+
state: s.state,
|
|
633
|
+
info: s.info,
|
|
634
|
+
segment: (s.checkpoint?.segment ?? 0) + 1,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
this.store.put("checkpoints", JSON.stringify([id, s.activeTurnId, s.checkpoint.segment]), { checkpoint: s.checkpoint, status: "runnable" });
|
|
638
|
+
s.status = "runnable";
|
|
639
|
+
s.waits = undefined;
|
|
640
|
+
schedule = true;
|
|
641
|
+
event = this.store.event(id, s.activeTurnId, `command.${command.type}`, command);
|
|
642
|
+
}
|
|
643
|
+
this.store.put("sessions", id, s);
|
|
644
|
+
const response = {
|
|
645
|
+
status: "accepted",
|
|
646
|
+
turnId: s.activeTurnId,
|
|
647
|
+
cursor: event?.cursor ?? this.store.history(id).cursor,
|
|
648
|
+
requestId: command.requestId,
|
|
649
|
+
};
|
|
650
|
+
this.store.put("commands", key, { command, response });
|
|
651
|
+
return response;
|
|
652
|
+
});
|
|
653
|
+
if (event)
|
|
654
|
+
this.publish(event);
|
|
655
|
+
if (input.type === "cancel")
|
|
656
|
+
this.running.get(id)?.abort();
|
|
657
|
+
if (schedule)
|
|
658
|
+
this.schedule(id);
|
|
659
|
+
return response;
|
|
660
|
+
}
|
|
661
|
+
sse(request, response, set) {
|
|
662
|
+
response.writeHead(200, {
|
|
663
|
+
"content-type": "text/event-stream",
|
|
664
|
+
"cache-control": "no-cache",
|
|
665
|
+
connection: "keep-alive",
|
|
666
|
+
"x-accel-buffering": "no",
|
|
667
|
+
});
|
|
668
|
+
response.flushHeaders();
|
|
669
|
+
set.add(response);
|
|
670
|
+
const timer = setInterval(() => this.send(response, ": keepalive\n\n"), 15000);
|
|
671
|
+
timer.unref();
|
|
672
|
+
request.on("close", () => {
|
|
673
|
+
clearInterval(timer);
|
|
674
|
+
set.delete(response);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
async handle(request, response) {
|
|
678
|
+
const json = (value, status = 200) => {
|
|
679
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
680
|
+
response.end(JSON.stringify(value));
|
|
681
|
+
};
|
|
682
|
+
try {
|
|
683
|
+
const url = new URL(request.url ?? "/", "http://runtime");
|
|
684
|
+
const path = url.pathname
|
|
685
|
+
.split("/")
|
|
686
|
+
.filter(Boolean)
|
|
687
|
+
.map(decodeURIComponent);
|
|
688
|
+
const method = request.method;
|
|
689
|
+
if (url.pathname === "/health")
|
|
690
|
+
return json({ status: "ok", service: "oss-runtime" });
|
|
691
|
+
if (url.pathname === "/ready") {
|
|
692
|
+
this.store.db.prepare("SELECT 1").get();
|
|
693
|
+
return json({
|
|
694
|
+
status: this.closing ? "not_ready" : "ready",
|
|
695
|
+
service: "oss-runtime",
|
|
696
|
+
checks: {
|
|
697
|
+
sqlite: true,
|
|
698
|
+
scheduler: !this.closing,
|
|
699
|
+
modelConfigured: true,
|
|
700
|
+
},
|
|
701
|
+
}, this.closing ? 503 : 200);
|
|
702
|
+
}
|
|
703
|
+
const scope = this.scope(request);
|
|
704
|
+
if (path[0] !== "v1")
|
|
705
|
+
fail(404, "Route not found");
|
|
706
|
+
if (path[1] === "executors" &&
|
|
707
|
+
path[2] === "connect" &&
|
|
708
|
+
method === "GET") {
|
|
709
|
+
if (scope === "server")
|
|
710
|
+
fail(403, "Executor credential required");
|
|
711
|
+
this.sse(request, response, this.executors);
|
|
712
|
+
this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (path[1] === "actions") {
|
|
716
|
+
if (scope === "server")
|
|
717
|
+
return fail(403, "Executor credential required");
|
|
718
|
+
this.expireClaims();
|
|
719
|
+
if (path.length === 2 && method === "GET")
|
|
720
|
+
return json({
|
|
721
|
+
actions: this.store
|
|
722
|
+
.all("actions")
|
|
723
|
+
.filter((a) => a.status === "pending" && a.agentId === scope.agentId),
|
|
724
|
+
});
|
|
725
|
+
const actionId = path[2];
|
|
726
|
+
if (!actionId)
|
|
727
|
+
fail(404, "Action not found");
|
|
728
|
+
const body = await this.body(request);
|
|
729
|
+
let event;
|
|
730
|
+
const result = this.store.tx(() => {
|
|
731
|
+
const action = this.store.get("actions", actionId) ??
|
|
732
|
+
fail(404, "Action not found");
|
|
733
|
+
this.scoped(scope, action);
|
|
734
|
+
if (method === "POST" && path[3] === "claim") {
|
|
735
|
+
ActionClaimRequestSchema.parse(body);
|
|
736
|
+
if (action.status !== "pending" ||
|
|
737
|
+
this.session(action.sessionId).status === "cancelled" ||
|
|
738
|
+
this.session(action.sessionId).activeTurnId !== action.turnId)
|
|
739
|
+
fail(409, "Action unavailable");
|
|
740
|
+
action.status = "claimed";
|
|
741
|
+
action.generation++;
|
|
742
|
+
action.claimId = randomUUID();
|
|
743
|
+
action.leaseExpiresAt = new Date(Date.now() + (this.options.leaseMs ?? 30000)).toISOString();
|
|
744
|
+
this.store.put("actions", actionId, action);
|
|
745
|
+
event = this.store.event(action.sessionId, action.turnId, "action.claimed", { actionId, generation: action.generation });
|
|
746
|
+
return {
|
|
747
|
+
action,
|
|
748
|
+
claimId: action.claimId,
|
|
749
|
+
generation: action.generation,
|
|
750
|
+
leaseExpiresAt: action.leaseExpiresAt,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
if (method === "POST" && path[3] === "heartbeat") {
|
|
754
|
+
const beat = ActionHeartbeatRequestSchema.parse(body);
|
|
755
|
+
if (this.session(action.sessionId).activeTurnId !== action.turnId ||
|
|
756
|
+
action.status !== "claimed" ||
|
|
757
|
+
action.claimId !== beat.claimId ||
|
|
758
|
+
action.generation !== beat.generation ||
|
|
759
|
+
Date.parse(action.leaseExpiresAt) <= Date.now())
|
|
760
|
+
fail(409, "Stale or expired claim");
|
|
761
|
+
action.leaseExpiresAt = new Date(Date.now() + (this.options.leaseMs ?? 30000)).toISOString();
|
|
762
|
+
this.store.put("actions", actionId, action);
|
|
763
|
+
return { leaseExpiresAt: action.leaseExpiresAt };
|
|
764
|
+
}
|
|
765
|
+
return fail(404, "Route not found");
|
|
766
|
+
});
|
|
767
|
+
if (event)
|
|
768
|
+
this.publish(event);
|
|
769
|
+
return json(result);
|
|
770
|
+
}
|
|
771
|
+
if (path[1] === "sessions" &&
|
|
772
|
+
path[2] &&
|
|
773
|
+
path[3] === "commands" &&
|
|
774
|
+
method === "POST")
|
|
775
|
+
return json(this.command(path[2], SessionCommandSchema.parse(await this.body(request)), scope));
|
|
776
|
+
if (path[1] === "vaults")
|
|
777
|
+
return json(await this.dispatchVault(scope, method, path, url, request));
|
|
778
|
+
if (scope !== "server")
|
|
779
|
+
fail(403, "Application credential required");
|
|
780
|
+
if (path[1] === "agents" && path.length === 2 && method === "GET")
|
|
781
|
+
return json({
|
|
782
|
+
agents: this.store.all("definitions").map((d) => ({
|
|
783
|
+
agentId: d.manifest.id,
|
|
784
|
+
manifest: d.manifest,
|
|
785
|
+
manifestHash: d.manifestHash,
|
|
786
|
+
implementationVersion: d.implementationVersion,
|
|
787
|
+
})),
|
|
788
|
+
});
|
|
789
|
+
if (path[1] === "sessions" && path.length === 2 && method === "GET")
|
|
790
|
+
return json({
|
|
791
|
+
sessions: this.store
|
|
792
|
+
.all("sessions")
|
|
793
|
+
.filter((s) => !url.searchParams.has("agentId") ||
|
|
794
|
+
s.agentId === url.searchParams.get("agentId"))
|
|
795
|
+
.map((s) => ({
|
|
796
|
+
id: s.id,
|
|
797
|
+
agentId: s.agentId,
|
|
798
|
+
ownerUserId: s.ownerUserId,
|
|
799
|
+
status: s.status,
|
|
800
|
+
activeTurnId: s.activeTurnId,
|
|
801
|
+
})),
|
|
802
|
+
});
|
|
803
|
+
if (path[1] === "agents" &&
|
|
804
|
+
path[2] &&
|
|
805
|
+
path.length === 3 &&
|
|
806
|
+
method === "PUT") {
|
|
807
|
+
const body = PutAgentRequestSchema.parse(await this.body(request));
|
|
808
|
+
if (body.manifest.id !== path[2])
|
|
809
|
+
fail(400, "Agent id mismatch");
|
|
810
|
+
AgentManifestSchema.parse(body.manifest);
|
|
811
|
+
const definition = {
|
|
812
|
+
...body,
|
|
813
|
+
manifestHash: hashManifest(body.manifest),
|
|
814
|
+
};
|
|
815
|
+
this.store.tx(() => {
|
|
816
|
+
this.store.put("definitions", path[2], definition);
|
|
817
|
+
});
|
|
818
|
+
return json({
|
|
819
|
+
agentId: path[2],
|
|
820
|
+
manifestHash: definition.manifestHash,
|
|
821
|
+
implementationVersion: body.implementationVersion,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
if (path[1] === "sessions" && path[2]) {
|
|
825
|
+
const id = path[2];
|
|
826
|
+
if (method === "PUT" && path.length === 3) {
|
|
827
|
+
const body = PutSessionRequestSchema.parse(await this.body(request));
|
|
828
|
+
const vaultIds = body.vaultIds ?? [];
|
|
829
|
+
const credentialSelections = body.credentialSelections ?? [];
|
|
830
|
+
const result = this.store.tx(() => {
|
|
831
|
+
this.vault.assertAttachment(body.ownerUserId, vaultIds, credentialSelections);
|
|
832
|
+
const prior = this.store.get("sessions", id);
|
|
833
|
+
if (prior) {
|
|
834
|
+
if (sessionIdentity(prior.creation) !== sessionIdentity(body))
|
|
835
|
+
fail(409, "Session already exists with different creation parameters");
|
|
836
|
+
prior.vaultIds = vaultIds;
|
|
837
|
+
prior.credentialSelections = credentialSelections;
|
|
838
|
+
prior.creation = body;
|
|
839
|
+
this.store.put("sessions", id, prior);
|
|
840
|
+
this.vault.recordAttachment(id, vaultIds);
|
|
841
|
+
return prior;
|
|
842
|
+
}
|
|
843
|
+
const definition = this.store.get("definitions", body.agentId) ??
|
|
844
|
+
fail(404, "Definition not found");
|
|
845
|
+
const s = {
|
|
846
|
+
id,
|
|
847
|
+
agentId: body.agentId,
|
|
848
|
+
ownerUserId: body.ownerUserId,
|
|
849
|
+
manifest: definition.manifest,
|
|
850
|
+
manifestHash: definition.manifestHash,
|
|
851
|
+
implementationVersion: definition.implementationVersion,
|
|
852
|
+
info: body.info,
|
|
853
|
+
status: "idle",
|
|
854
|
+
activeTurnId: null,
|
|
855
|
+
creation: body,
|
|
856
|
+
vaultIds,
|
|
857
|
+
credentialSelections,
|
|
858
|
+
pluginRoots: definition.pluginRoots ?? {},
|
|
859
|
+
};
|
|
860
|
+
this.store.put("sessions", id, s);
|
|
861
|
+
this.vault.recordAttachment(id, vaultIds);
|
|
862
|
+
return s;
|
|
863
|
+
});
|
|
864
|
+
return json(this.view(result));
|
|
865
|
+
}
|
|
866
|
+
const s = this.session(id);
|
|
867
|
+
if (method === "GET" && path.length === 3)
|
|
868
|
+
return json(this.view(s));
|
|
869
|
+
const cursor = url.searchParams.get("cursor") ??
|
|
870
|
+
(typeof request.headers["last-event-id"] === "string"
|
|
871
|
+
? request.headers["last-event-id"]
|
|
872
|
+
: undefined);
|
|
873
|
+
if (method === "GET" && path[3] === "items")
|
|
874
|
+
return json(this.store.history(id, cursor));
|
|
875
|
+
if (method === "GET" && path[3] === "events") {
|
|
876
|
+
const history = this.store.history(id, cursor);
|
|
877
|
+
const set = this.observers.get(id) ?? new Set();
|
|
878
|
+
this.observers.set(id, set);
|
|
879
|
+
this.sse(request, response, set);
|
|
880
|
+
for (const event of history.items)
|
|
881
|
+
this.send(response, `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
fail(404, "Route not found");
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
if (response.headersSent) {
|
|
889
|
+
response.end();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const status = error instanceof HttpError || error instanceof VaultError
|
|
893
|
+
? error.status
|
|
894
|
+
: error?.name === "ZodError" ||
|
|
895
|
+
error?.message === "Invalid cursor"
|
|
896
|
+
? 400
|
|
897
|
+
: 500;
|
|
898
|
+
json({
|
|
899
|
+
status: "rejected",
|
|
900
|
+
code: status === 500 ? "internal_error" : "request_rejected",
|
|
901
|
+
message: status === 500
|
|
902
|
+
? "Runtime request failed"
|
|
903
|
+
: error.message,
|
|
904
|
+
}, status);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
view(s) {
|
|
908
|
+
return {
|
|
909
|
+
id: s.id,
|
|
910
|
+
agentId: s.agentId,
|
|
911
|
+
ownerUserId: s.ownerUserId,
|
|
912
|
+
manifestHash: s.manifestHash,
|
|
913
|
+
implementationVersion: s.implementationVersion,
|
|
914
|
+
status: s.status,
|
|
915
|
+
activeTurnId: s.activeTurnId,
|
|
916
|
+
vaultIds: s.vaultIds ?? [],
|
|
917
|
+
credentialSelections: s.credentialSelections ?? [],
|
|
918
|
+
mcpSnapshot: s.mcpSnapshot ?? null,
|
|
919
|
+
mcpDiagnostics: s.mcpDiagnostics ?? [],
|
|
920
|
+
waits: Array.isArray(s.waits)
|
|
921
|
+
? s.waits.map((call) => ({
|
|
922
|
+
invocationId: call.invocationId,
|
|
923
|
+
interaction: call.interaction,
|
|
924
|
+
wait: call.wait,
|
|
925
|
+
status: call.status,
|
|
926
|
+
}))
|
|
927
|
+
: s.waits,
|
|
928
|
+
error: s.error,
|
|
929
|
+
actions: this.store
|
|
930
|
+
.all("actions")
|
|
931
|
+
.filter((a) => a.sessionId === s.id &&
|
|
932
|
+
!["completed", "cancelled"].includes(a.status)),
|
|
933
|
+
uncertainEffects: this.store
|
|
934
|
+
.all("effects")
|
|
935
|
+
.filter((e) => e.request.sessionId === s.id && e.status === "uncertain")
|
|
936
|
+
.map((e) => ({
|
|
937
|
+
effectId: e.request.effectId,
|
|
938
|
+
turnId: e.request.turnId,
|
|
939
|
+
kind: e.request.kind,
|
|
940
|
+
error: e.error,
|
|
941
|
+
})),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
async prepareMcp(id, signal) {
|
|
945
|
+
const s = this.session(id);
|
|
946
|
+
const declared = s.manifest.capabilities.some((capability) => capability.mcpServers !== undefined &&
|
|
947
|
+
Object.keys(capability.mcpServers).length > 0);
|
|
948
|
+
if (!declared)
|
|
949
|
+
return;
|
|
950
|
+
if (!s.mcpSnapshot) {
|
|
951
|
+
const found = await this.mcp.discover({
|
|
952
|
+
sessionId: id,
|
|
953
|
+
manifest: s.manifest,
|
|
954
|
+
manifestHash: s.manifestHash,
|
|
955
|
+
pluginRoots: s.pluginRoots ?? {},
|
|
956
|
+
signal,
|
|
957
|
+
});
|
|
958
|
+
this.store.tx(() => {
|
|
959
|
+
const current = this.session(id);
|
|
960
|
+
if (current.mcpSnapshot)
|
|
961
|
+
return;
|
|
962
|
+
current.mcpSnapshot = found.snapshot;
|
|
963
|
+
current.mcpDiagnostics = found.diagnostics;
|
|
964
|
+
this.store.put("sessions", id, current);
|
|
965
|
+
});
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const diagnostics = await this.mcp.reconnect({
|
|
969
|
+
sessionId: id,
|
|
970
|
+
manifest: s.manifest,
|
|
971
|
+
pluginRoots: s.pluginRoots ?? {},
|
|
972
|
+
tools: s.mcpSnapshot.mcpTools,
|
|
973
|
+
signal,
|
|
974
|
+
});
|
|
975
|
+
if (diagnostics.length === 0)
|
|
976
|
+
return;
|
|
977
|
+
this.store.tx(() => {
|
|
978
|
+
const current = this.session(id);
|
|
979
|
+
const prior = [...(current.mcpDiagnostics ?? [])];
|
|
980
|
+
for (const item of diagnostics) {
|
|
981
|
+
const index = prior.findIndex((existing) => existing.capabilityId === item.capabilityId &&
|
|
982
|
+
existing.serverName === item.serverName);
|
|
983
|
+
if (index >= 0)
|
|
984
|
+
prior[index] = item;
|
|
985
|
+
else
|
|
986
|
+
prior.push(item);
|
|
987
|
+
}
|
|
988
|
+
current.mcpDiagnostics = prior;
|
|
989
|
+
this.store.put("sessions", id, current);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
async callMcpTool(request) {
|
|
993
|
+
const s = this.session(request.sessionId);
|
|
994
|
+
const tool = mcpToolOf(s, request.capabilityId, request.toolName);
|
|
995
|
+
if (!tool)
|
|
996
|
+
throw new Error(`MCP tool '${request.toolName ?? ""}' is not in the session snapshot`);
|
|
997
|
+
return this.mcp.call({
|
|
998
|
+
sessionId: s.id,
|
|
999
|
+
capabilityId: tool.capabilityId,
|
|
1000
|
+
serverName: tool.serverName,
|
|
1001
|
+
serverToolName: tool.serverToolName,
|
|
1002
|
+
args: request.input,
|
|
1003
|
+
manifest: s.manifest,
|
|
1004
|
+
pluginRoots: s.pluginRoots ?? {},
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
async authorize(sessionId, request) {
|
|
1008
|
+
const s = this.session(sessionId);
|
|
1009
|
+
const result = await this.vault.authorize({
|
|
1010
|
+
sessionId,
|
|
1011
|
+
vaultIds: s.vaultIds ?? [],
|
|
1012
|
+
credentialSelections: s.credentialSelections ?? [],
|
|
1013
|
+
url: request.url,
|
|
1014
|
+
serverName: request.serverName,
|
|
1015
|
+
});
|
|
1016
|
+
if (result.status === "authorized") {
|
|
1017
|
+
const token = result.headers.authorization.slice("Bearer ".length);
|
|
1018
|
+
scrub({ authorization: result.headers.authorization, url: result.url }, [
|
|
1019
|
+
token,
|
|
1020
|
+
]);
|
|
1021
|
+
}
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
1024
|
+
ensureKek() {
|
|
1025
|
+
if (this.kek)
|
|
1026
|
+
return this.kek;
|
|
1027
|
+
if (this.options.vaultKek === null)
|
|
1028
|
+
throw new Error("Vault key-encryption key is required");
|
|
1029
|
+
this.kek = createKekFile(this.kekPath);
|
|
1030
|
+
return this.kek;
|
|
1031
|
+
}
|
|
1032
|
+
async dispatchVault(scope, method, path, url, request) {
|
|
1033
|
+
if (scope !== "server") {
|
|
1034
|
+
this.vault.reject(path.join("/"));
|
|
1035
|
+
fail(403, "Application credential required");
|
|
1036
|
+
}
|
|
1037
|
+
if (path.length === 2 && method === "POST") {
|
|
1038
|
+
const body = CreateVaultRequestSchema.parse(await this.body(request));
|
|
1039
|
+
return this.vault.createVault(body);
|
|
1040
|
+
}
|
|
1041
|
+
if (path.length === 2 && method === "GET") {
|
|
1042
|
+
const ownerUserId = url.searchParams.get("ownerUserId") ??
|
|
1043
|
+
fail(400, "ownerUserId is required");
|
|
1044
|
+
return { vaults: this.vault.listVaults(ownerUserId) };
|
|
1045
|
+
}
|
|
1046
|
+
const vaultId = path[2];
|
|
1047
|
+
if (!vaultId)
|
|
1048
|
+
fail(404, "Vault not found");
|
|
1049
|
+
if (path.length === 3 && method === "GET")
|
|
1050
|
+
return this.vault.getVault(vaultId);
|
|
1051
|
+
if (path.length === 3 && method === "DELETE")
|
|
1052
|
+
return this.vault.deleteVault(vaultId);
|
|
1053
|
+
if (path[3] !== "credentials")
|
|
1054
|
+
fail(404, "Route not found");
|
|
1055
|
+
if (path.length === 4 && method === "POST") {
|
|
1056
|
+
const body = CreateCredentialRequestSchema.parse(await this.body(request));
|
|
1057
|
+
return this.vault.createCredential(vaultId, body);
|
|
1058
|
+
}
|
|
1059
|
+
if (path.length === 4 && method === "GET")
|
|
1060
|
+
return { credentials: this.vault.listCredentials(vaultId) };
|
|
1061
|
+
const credentialId = path[4];
|
|
1062
|
+
if (!credentialId)
|
|
1063
|
+
fail(404, "Credential not found");
|
|
1064
|
+
if (path.length === 5 && method === "GET")
|
|
1065
|
+
return this.vault.getCredential(vaultId, credentialId);
|
|
1066
|
+
if (path.length === 5 && method === "POST") {
|
|
1067
|
+
const body = RotateCredentialRequestSchema.parse(await this.body(request));
|
|
1068
|
+
return this.vault.rotateCredential(vaultId, credentialId, body);
|
|
1069
|
+
}
|
|
1070
|
+
if (path.length === 5 && method === "DELETE")
|
|
1071
|
+
return this.vault.deleteCredential(vaultId, credentialId);
|
|
1072
|
+
fail(404, "Route not found");
|
|
1073
|
+
}
|
|
1074
|
+
async listen(port = 8787, hostname = "127.0.0.1") {
|
|
1075
|
+
if (this.server)
|
|
1076
|
+
throw new Error("Already listening");
|
|
1077
|
+
this.server = createServer((q, s) => {
|
|
1078
|
+
void this.handle(q, s);
|
|
1079
|
+
});
|
|
1080
|
+
await new Promise((resolve, reject) => {
|
|
1081
|
+
this.server.once("error", reject);
|
|
1082
|
+
this.server.listen(port, hostname, resolve);
|
|
1083
|
+
});
|
|
1084
|
+
const address = this.server.address();
|
|
1085
|
+
return {
|
|
1086
|
+
url: `http://${hostname}:${typeof address === "object" && address ? address.port : port}`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
async close() {
|
|
1090
|
+
this.closing = true;
|
|
1091
|
+
clearInterval(this.timer);
|
|
1092
|
+
for (const c of this.running.values())
|
|
1093
|
+
c.abort();
|
|
1094
|
+
await this.mcp.close();
|
|
1095
|
+
for (const r of this.executors)
|
|
1096
|
+
r.end();
|
|
1097
|
+
for (const set of this.observers.values())
|
|
1098
|
+
for (const r of set)
|
|
1099
|
+
r.end();
|
|
1100
|
+
if (this.server)
|
|
1101
|
+
await new Promise((resolve) => {
|
|
1102
|
+
this.server.close(() => resolve());
|
|
1103
|
+
this.server.closeIdleConnections();
|
|
1104
|
+
});
|
|
1105
|
+
// Providers must honor AbortSignal. Keep storage open until any in-flight journal writes have drained.
|
|
1106
|
+
while (this.running.size)
|
|
1107
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1108
|
+
this.store.db.close();
|
|
1109
|
+
if (this.lockPath)
|
|
1110
|
+
unlinkSync(this.lockPath);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
export function createRuntime(options) {
|
|
1114
|
+
return new CoreRuntime(options);
|
|
1115
|
+
}
|
|
1116
|
+
export async function startRuntime(options) {
|
|
1117
|
+
const runtime = createRuntime(options);
|
|
1118
|
+
try {
|
|
1119
|
+
const { url } = await runtime.listen(options.port, options.hostname);
|
|
1120
|
+
return Object.assign(runtime, { url });
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
await runtime.close();
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
}
|