@nylorun/runtime 0.36.0-beta → 0.37.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 (59) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +10 -0
  3. package/dist/api/a2a/card.d.ts +10 -4
  4. package/dist/api/a2a/card.js +18 -6
  5. package/dist/api/a2a/endpoint.d.ts +5 -2
  6. package/dist/api/a2a/endpoint.js +31 -9
  7. package/dist/api/a2a/extensions.d.ts +41 -0
  8. package/dist/api/a2a/extensions.js +92 -0
  9. package/dist/api/a2a/list.d.ts +60 -0
  10. package/dist/api/a2a/list.js +169 -0
  11. package/dist/api/a2a/protocol.d.ts +105 -9
  12. package/dist/api/a2a/protocol.js +169 -18
  13. package/dist/api/a2a/push.d.ts +76 -0
  14. package/dist/api/a2a/push.js +305 -0
  15. package/dist/api/a2a/routes.d.ts +12 -6
  16. package/dist/api/a2a/routes.js +409 -112
  17. package/dist/api/a2a/tasks.d.ts +72 -4
  18. package/dist/api/a2a/tasks.js +305 -27
  19. package/dist/api/components.d.ts +532 -0
  20. package/dist/api/components.js +3 -1
  21. package/dist/api/http/app.js +2 -0
  22. package/dist/api/http/routes/artifacts.js +19 -0
  23. package/dist/api/http/routes/turns.d.ts +18 -0
  24. package/dist/api/http/routes/turns.js +67 -0
  25. package/dist/api/inbound-files.d.ts +12 -1
  26. package/dist/api/inbound-files.js +44 -0
  27. package/dist/api/openapi.js +7 -2
  28. package/dist/host/config-for.d.ts +2 -0
  29. package/dist/host/config-for.js +1 -0
  30. package/dist/host/main.js +1 -0
  31. package/dist/host/stack-config.d.ts +16 -0
  32. package/dist/host/stack-config.js +30 -0
  33. package/dist/management-openapi.json +5 -2
  34. package/dist/openapi.json +1485 -738
  35. package/dist/reads/types.d.ts +19 -0
  36. package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
  37. package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
  38. package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
  39. package/dist/store/postgres/drizzle/meta/_journal.json +21 -0
  40. package/dist/store/postgres/reads.js +41 -1
  41. package/dist/store/postgres/schema.d.ts +1296 -534
  42. package/dist/store/postgres/schema.js +92 -0
  43. package/dist/store/postgres/store.js +133 -3
  44. package/dist/store/postgres/turns.d.ts +62 -0
  45. package/dist/store/postgres/turns.js +153 -0
  46. package/dist/store/types.d.ts +73 -3
  47. package/dist/tenant/runtime.js +7 -5
  48. package/dist/tenant/session-model.d.ts +11 -1
  49. package/dist/tenant/session-model.js +34 -5
  50. package/dist/tenant/sessions.d.ts +2 -1
  51. package/dist/tenant/sessions.js +8 -2
  52. package/dist/tenant/sweep.d.ts +3 -0
  53. package/dist/tenant/sweep.js +39 -11
  54. package/dist/tenant/types.d.ts +23 -0
  55. package/dist/tuning.d.ts +9 -0
  56. package/dist/tuning.js +11 -0
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +3 -3
@@ -0,0 +1,305 @@
1
+ import { signWebhook, webhookKeyOf } from "../../keys/webhooks.js";
2
+ import { guardedFetch, OutboundRefused, refusal } from "../../tenant/outbound.js";
3
+ import { traceKey } from "../../traces/key.js";
4
+ import { traceIdOf } from "../../traces/ids.js";
5
+ import { hostTuning } from "../../tuning.js";
6
+ import { ALL_EXTENSIONS, keepExtensions } from "./extensions.js";
7
+ import { a2aFail, } from "./protocol.js";
8
+ import { ENDED, callerTraceId, updatesOf } from "./tasks.js";
9
+ /** The most push configs one task keeps. A constant of the profile, not a setting. */
10
+ export const MAX_PUSH_CONFIGS = 10;
11
+ /** Events one pump reads at a time. */
12
+ const PUMP_BATCH = 500;
13
+ /** Sessions one sweep pass pumps. */
14
+ const SWEEP_SESSIONS = 500;
15
+ /** The most of a receiver's answer read: it is not used. */
16
+ const ANSWER_BYTES = 64 * 1024;
17
+ /** Whether this Runtime serves push notifications: it has a secret to sign them with. */
18
+ export function pushEnabled(ctx) {
19
+ return ctx.config.a2a?.push?.secret !== undefined;
20
+ }
21
+ /** `host:port` of `url`, the way `a2a.push.allowHosts` names it. */
22
+ function hostPort(url) {
23
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
24
+ return `${url.hostname.toLowerCase()}:${port}`;
25
+ }
26
+ /**
27
+ * The address policy a push, or a fetched file `url` part, is sent under: the Host's delivery
28
+ * policy, with private addresses refused unless `url`'s `host:port` is in `a2a.push.allowHosts`.
29
+ */
30
+ export function pushPolicy(ctx, target) {
31
+ const url = new URL(target);
32
+ const hosts = ctx.config.a2a?.push?.allowHosts ?? [];
33
+ const allowed = hosts.includes(hostPort(url)) || hosts.includes(`${url.hostname.toLowerCase()}:*`);
34
+ return { ...(ctx.config.delivery ?? {}), privateAddresses: allowed ? "allow" : "refuse" };
35
+ }
36
+ /** Why the Runtime will not send to `target`, checked before a config is stored; else undefined. */
37
+ export function pushRefusal(ctx, target) {
38
+ return refusal(new URL(target), pushPolicy(ctx, target));
39
+ }
40
+ /** A config as A2A answers it. */
41
+ export function configView(row) {
42
+ return {
43
+ id: row.configId,
44
+ taskId: row.taskId,
45
+ url: row.url,
46
+ ...(row.token ? { token: row.token } : {}),
47
+ ...(row.authentication ? { authentication: row.authentication } : {}),
48
+ };
49
+ }
50
+ /**
51
+ * Stores `config` for a task (replacing one with its id), in `t`; the pump sends its updates.
52
+ * A URL the Runtime will not call is `InvalidParamsError`; past `MAX_PUSH_CONFIGS` too.
53
+ */
54
+ export async function savePushConfig(ctx, t, start, config, configId) {
55
+ if (!pushEnabled(ctx))
56
+ a2aFail("pushNotSupported", "Push notifications are not configured on this Runtime (a2a.push.secret)");
57
+ const refused = pushRefusal(ctx, config.url);
58
+ if (refused)
59
+ a2aFail("invalidParams", `The push URL is refused: ${refused}`, {}, "url");
60
+ const existing = await t.pushConfigs(start.sessionId, start.taskId);
61
+ if (existing.length >= MAX_PUSH_CONFIGS && !existing.some((row) => row.configId === configId))
62
+ a2aFail("invalidParams", `A task keeps at most ${MAX_PUSH_CONFIGS} push configs`, {}, "id");
63
+ const row = {
64
+ sessionId: start.sessionId,
65
+ taskId: start.taskId,
66
+ configId,
67
+ contextId: start.contextId,
68
+ turnId: start.turnId,
69
+ url: config.url,
70
+ token: config.token ?? null,
71
+ authentication: config.authentication ?? null,
72
+ baseUrl: start.baseUrl,
73
+ extensions: [...(start.extensions.size === 0 ? ALL_EXTENSIONS : start.extensions)],
74
+ traceId: start.traceId ?? null,
75
+ nextSeq: start.nextSeq,
76
+ doneAt: start.done ? new Date() : null,
77
+ };
78
+ await t.putPushConfig(row);
79
+ return configView({ ...row, createdAt: new Date(), doneAt: row.doneAt });
80
+ }
81
+ const states = new WeakMap();
82
+ function stateOf(ctx) {
83
+ let state = states.get(ctx);
84
+ if (!state) {
85
+ state = { active: new Set(), pumping: new Map(), again: new Set(), listening: false };
86
+ states.set(ctx, state);
87
+ }
88
+ return state;
89
+ }
90
+ /**
91
+ * Pumps session `sessionId` on this process: at once, or again after the pump running now, so
92
+ * one process never pumps a session twice at the same time. Never rejects.
93
+ */
94
+ export function kickPushes(ctx, sessionId) {
95
+ const state = stateOf(ctx);
96
+ listen(ctx, state);
97
+ state.active.add(sessionId);
98
+ const running = state.pumping.get(sessionId);
99
+ if (running) {
100
+ state.again.add(sessionId);
101
+ return running;
102
+ }
103
+ const run = (async () => {
104
+ do {
105
+ state.again.delete(sessionId);
106
+ if (ctx.closing || ctx.closed)
107
+ return;
108
+ try {
109
+ await pumpSession(ctx, sessionId);
110
+ }
111
+ catch (error) {
112
+ ctx.config.logger.warn("a2a push pump failed; the sweep tries again", {
113
+ sessionId,
114
+ message: error instanceof Error ? error.message : String(error),
115
+ });
116
+ }
117
+ } while (state.again.has(sessionId));
118
+ })().finally(() => state.pumping.delete(sessionId));
119
+ state.pumping.set(sessionId, run);
120
+ return run;
121
+ }
122
+ /** A commit on this process that wrote events of a session with a config pumps it. */
123
+ function listen(ctx, state) {
124
+ if (state.listening)
125
+ return;
126
+ state.listening = true;
127
+ ctx.store.onCommit(({ events }) => {
128
+ const sessions = new Set(events.map((event) => event.sessionId));
129
+ for (const sessionId of sessions)
130
+ if (state.active.has(sessionId))
131
+ void kickPushes(ctx, sessionId);
132
+ });
133
+ }
134
+ /**
135
+ * Translates the events of session `sessionId` its configs have not read into push rows: reads
136
+ * the record from the lowest `nextSeq`, then, under the configs' locks, writes each config's
137
+ * updates and moves it on. A config stored behind what was read waits for the next pump.
138
+ */
139
+ export async function pumpSession(ctx, sessionId) {
140
+ for (;;) {
141
+ const configs = await ctx.store.tx((t) => t.activePushConfigs(sessionId));
142
+ if (configs.length === 0) {
143
+ stateOf(ctx).active.delete(sessionId);
144
+ return;
145
+ }
146
+ const from = Math.min(...configs.map((config) => config.nextSeq));
147
+ const rows = await ctx.store.record().readRange(ctx.store.tenantId, sessionId, from, from + PUMP_BATCH);
148
+ if (rows.length === 0) {
149
+ // A deleted session's configs follow nothing any more.
150
+ await ctx.store.tx(async (t) => {
151
+ if (await t.get("sessions", sessionId))
152
+ return;
153
+ for (const config of configs)
154
+ await t.deletePushConfig(sessionId, config.taskId, config.configId);
155
+ });
156
+ return;
157
+ }
158
+ const events = rows.map((row) => row.body);
159
+ const key = configs.some((config) => config.traceId === null) ? await traceKey(ctx.store) : undefined;
160
+ await ctx.store.tx(async (t) => {
161
+ for (const config of await t.activePushConfigs(sessionId, { lock: true })) {
162
+ if (config.nextSeq < from)
163
+ continue;
164
+ let { turnId, traceId } = config;
165
+ let next = config.nextSeq;
166
+ let done = false;
167
+ for (const event of events) {
168
+ if (event.seq < config.nextSeq)
169
+ continue;
170
+ next = event.seq + 1;
171
+ const payload = (event.payload ?? {});
172
+ // A queued message's task follows the turn that takes it.
173
+ if (event.type === "command.message" && event.turnId && payload.queuedMessageId === turnId)
174
+ turnId = event.turnId;
175
+ if (event.turnId !== turnId)
176
+ continue;
177
+ if (event.type === "command.message" && traceId === null)
178
+ traceId =
179
+ callerTraceId([event]) ??
180
+ (key ? traceIdOf(key, ctx.store.tenantId, sessionId, turnId) : null);
181
+ const view = {
182
+ contextId: config.contextId,
183
+ taskId: config.taskId,
184
+ baseUrl: config.baseUrl,
185
+ ...(traceId ? { traceId } : {}),
186
+ };
187
+ const active = new Set(config.extensions);
188
+ for (const update of updatesOf(event, view))
189
+ await t.enqueuePush({
190
+ sessionId,
191
+ taskId: config.taskId,
192
+ configId: config.configId,
193
+ body: JSON.stringify(keepExtensions(update, active)),
194
+ });
195
+ if (ENDED.has(event.type)) {
196
+ done = true;
197
+ break;
198
+ }
199
+ }
200
+ if (next !== config.nextSeq || done || turnId !== config.turnId || traceId !== config.traceId)
201
+ await t.advancePushConfig({ sessionId, taskId: config.taskId, configId: config.configId }, { turnId, nextSeq: next, traceId, done });
202
+ }
203
+ });
204
+ if (rows.length < PUMP_BATCH)
205
+ return;
206
+ }
207
+ }
208
+ /**
209
+ * The Tenant sweep's push step: pumps every session with a config still following its task
210
+ * (commits on other processes reach them here), and deletes configs done longer than the Host
211
+ * tuning's `a2a.pushConfigRetentionMs`.
212
+ */
213
+ export async function pumpPushes(ctx) {
214
+ const state = stateOf(ctx);
215
+ listen(ctx, state);
216
+ const sessions = await ctx.store.tx((t) => t.pushSessions(SWEEP_SESSIONS));
217
+ state.active = new Set([...sessions, ...state.pumping.keys()]);
218
+ for (const sessionId of sessions) {
219
+ if (ctx.closing || ctx.closed)
220
+ return;
221
+ await kickPushes(ctx, sessionId);
222
+ }
223
+ const before = new Date(Date.now() - hostTuning().a2a.pushConfigRetentionMs);
224
+ await ctx.store.tx((t) => t.prunePushConfigs(before));
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // Delivery
228
+ /** A push the receiver did not take: the sweep sends it again. */
229
+ export class PushFailed extends Error {
230
+ constructor(message) {
231
+ super(message);
232
+ this.name = "PushFailed";
233
+ }
234
+ }
235
+ /** The headers of one push attempt: Standard Webhooks, the config's token and authentication. */
236
+ export function pushHeaders(secret, push, config, now = Date.now()) {
237
+ const timestamp = String(Math.floor(now / 1000));
238
+ const key = webhookKeyOf(secret);
239
+ try {
240
+ const signature = signWebhook(key, push.id, timestamp, Buffer.from(push.body, "utf8"));
241
+ const authentication = config.authentication;
242
+ return {
243
+ "content-type": "application/json",
244
+ "webhook-id": push.id,
245
+ "webhook-timestamp": timestamp,
246
+ "webhook-signature": `v1,${signature}`,
247
+ ...(config.token ? { "x-a2a-notification-token": config.token } : {}),
248
+ ...(authentication ? { authorization: `${authentication.scheme} ${authentication.credentials}` } : {}),
249
+ };
250
+ }
251
+ finally {
252
+ key.fill(0);
253
+ }
254
+ }
255
+ /**
256
+ * Sends one push row (the Tenant's `send` for outbox kind `push`). Resolves `true` once it is
257
+ * done with: delivered (2xx), its config deleted, the receiver gone (`410`), or its URL refused
258
+ * by policy. Rejects with `PushFailed` (or the network's error) when it should be sent again.
259
+ */
260
+ export async function deliverPush(ctx, push) {
261
+ if (ctx.closing || ctx.closed)
262
+ return false;
263
+ const secret = ctx.config.a2a?.push?.secret;
264
+ if (secret === undefined)
265
+ throw new PushFailed("Push notifications are not configured (a2a.push.secret)");
266
+ const config = await ctx.store.tx((t) => t.pushConfig(push.sessionId, push.taskId, push.configId));
267
+ if (!config)
268
+ return true;
269
+ const send = guardedFetch(pushPolicy(ctx, config.url), {
270
+ maxResponseBytes: ANSWER_BYTES,
271
+ timeoutMs: hostTuning().a2a.pushTimeoutMs,
272
+ });
273
+ let status;
274
+ try {
275
+ const response = await send(config.url, {
276
+ method: "POST",
277
+ headers: pushHeaders(secret, push, config),
278
+ body: push.body,
279
+ });
280
+ status = response.status;
281
+ }
282
+ catch (error) {
283
+ if (error instanceof OutboundRefused) {
284
+ ctx.config.logger.warn("a2a push dropped: its URL is refused", {
285
+ sessionId: push.sessionId,
286
+ taskId: push.taskId,
287
+ message: error.message,
288
+ });
289
+ return true;
290
+ }
291
+ throw error;
292
+ }
293
+ if ((status >= 200 && status < 300) || status === 410)
294
+ return true;
295
+ throw new PushFailed(`The push receiver answered ${status}`);
296
+ }
297
+ /**
298
+ * What the sweep does with a push that failed `attempts` times and was written at `createdAt`:
299
+ * try again after the backoff, or drop it once it would be past `a2a.pushMaxAgeMs`.
300
+ */
301
+ export function pushRetry(attempts, createdAt, now = Date.now()) {
302
+ const { pushRetry: backoff, pushMaxAgeMs } = hostTuning().a2a;
303
+ const retryInMs = Math.min(backoff.minMs * 2 ** Math.max(0, attempts - 1), backoff.maxMs);
304
+ return now + retryInMs - Date.parse(createdAt) > pushMaxAgeMs ? "drop" : { retryInMs };
305
+ }
@@ -1,20 +1,26 @@
1
1
  import type { ServerResponse } from "node:http";
2
2
  import { type AuthScope, type TenantContext } from "../../tenant/context.js";
3
- import type { TraceContext } from "../../traces/ids.js";
3
+ import { type TraceContext } from "../../traces/ids.js";
4
+ /** The A2A endpoint's route for a published agent, from the Runtime's base URL. */
5
+ export declare function agentEndpoint(baseUrl: string, agentId: string): string;
4
6
  /**
5
7
  * `GET /v1/a2a/agents/:agent/card`: the agent's card, for a caller who may use the agent. Its
6
8
  * input modes name the files the Tenant's model reads natively: a hint, since any file is
7
- * accepted and the agent's harness decides how its model sees it (protocol 15).
9
+ * accepted and the agent's harness decides how its model sees it (protocol 15). Its interface is
10
+ * this Runtime's JSON-RPC endpoint at `baseUrl` (a gateway lists its own instead).
8
11
  */
9
- export declare function a2aCard(ctx: TenantContext, scope: AuthScope, agentId: string): Promise<unknown>;
12
+ export declare function a2aCard(ctx: TenantContext, scope: AuthScope, agentId: string, baseUrl: string): Promise<unknown>;
10
13
  /**
11
14
  * `POST /v1/a2a/agents/:agent`: one A2A JSON-RPC call, for a subject. JSON-RPC answers (errors
12
- * included) are the body to send with 200; HTTP-level refusals (unknown agent, no subject,
13
- * limits) are thrown as `HttpError` like any Tenant route. `body` is read once the agent is
14
- * known to be the caller's; closing `response` stops a blocking wait, never the task.
15
+ * included) are the body to send with 200, without the data of extensions the request did not
16
+ * activate; HTTP-level refusals (unknown agent, no subject, limits) are thrown as `HttpError`
17
+ * like any Tenant route. `body` is read once the agent is known to be the caller's; closing
18
+ * `response` stops a blocking wait, never the task.
15
19
  */
16
20
  export declare function a2aCall(ctx: TenantContext, scope: AuthScope, agentId: string, input: {
17
21
  body: () => Promise<string>;
18
22
  version: string | null;
19
23
  trace?: TraceContext;
24
+ extensions: ReadonlySet<string>;
25
+ baseUrl: string;
20
26
  }, response: ServerResponse): Promise<unknown>;