a2a-exposed 0.8.15 → 0.8.17
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/lib/deploy.mjs +33 -6
- package/lib/main.mjs +2 -1
- package/package.json +1 -1
- package/worker/migrations/0010_conversation_peer.sql +25 -0
- package/worker/migrations/0011_conversation_owner.sql +34 -0
- package/worker/src/export.ts +1 -1
- package/worker/src/index.ts +185 -54
- package/worker/src/migrations.ts +4 -0
- package/worker/src/version.ts +1 -1
- package/worker/test/context-peer.test.ts +171 -0
- package/worker/test/facade.test.ts +56 -1
- package/worker/test/migrations.test.ts +40 -5
- package/worker/test/version-header.test.ts +100 -0
package/lib/deploy.mjs
CHANGED
|
@@ -434,15 +434,41 @@ async function deployAndLearn(dir, secrets, o) {
|
|
|
434
434
|
* whoami before npm install. A previous install has node_modules/.bin/cf.
|
|
435
435
|
* A fresh init does not: `npx cf auth login` does not put `cf` on PATH, so this check uses `npx --yes cf`.
|
|
436
436
|
*/
|
|
437
|
-
function
|
|
437
|
+
function cfSpawn(dir, args, { allowFail = false } = {}) {
|
|
438
438
|
const local = path.join(dir, "node_modules", ".bin", "cf");
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
? run(local,
|
|
442
|
-
: run("npx", ["--yes", "cf", ...
|
|
439
|
+
const full = cfArgs(args);
|
|
440
|
+
return fs.existsSync(local)
|
|
441
|
+
? run(local, full, { cwd: dir, capture: true, allowFail })
|
|
442
|
+
: run("npx", ["--yes", "cf", ...full], { cwd: dir, capture: true, allowFail });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function cfLoginJson(dir) {
|
|
446
|
+
const r = cfSpawn(dir, ["auth", "whoami"]);
|
|
443
447
|
try { return JSON.parse(r.stdout); } catch { die("unexpected output from cf auth whoami"); }
|
|
444
448
|
}
|
|
445
449
|
|
|
450
|
+
function normalizeAccounts(raw) {
|
|
451
|
+
const list = Array.isArray(raw) ? raw : (raw && typeof raw === "object" && Array.isArray(raw.result) ? raw.result : []);
|
|
452
|
+
return list.filter((a) => a && typeof a.id === "string" && a.id).map((a) => ({ id: a.id, name: typeof a.name === "string" && a.name ? a.name : a.id }));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile. */
|
|
456
|
+
function accountsFromList(dir) {
|
|
457
|
+
const all = [];
|
|
458
|
+
const seen = new Set();
|
|
459
|
+
for (let page = 1; page <= 20; page++) {
|
|
460
|
+
const r = cfSpawn(dir, ["accounts", "list", "--page", String(page), "--per-page", "50"], { allowFail: true });
|
|
461
|
+
if (!r || r.status !== 0) return page === 1 ? [] : all;
|
|
462
|
+
let data;
|
|
463
|
+
try { data = JSON.parse(r.stdout); } catch { return page === 1 ? [] : all; }
|
|
464
|
+
if (data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.result)) data = data.result;
|
|
465
|
+
if (!Array.isArray(data)) return page === 1 ? [] : all;
|
|
466
|
+
for (const a of normalizeAccounts(data)) if (!seen.has(a.id)) { seen.add(a.id); all.push(a); }
|
|
467
|
+
if (data.length < 50) break;
|
|
468
|
+
}
|
|
469
|
+
return all;
|
|
470
|
+
}
|
|
471
|
+
|
|
446
472
|
/** Cloudflare login, before init installs packages. Dies with the same advice as a later cf failure. */
|
|
447
473
|
function requireCfLogin(dir) {
|
|
448
474
|
const prof = cfProfile();
|
|
@@ -454,7 +480,8 @@ function requireCfLogin(dir) {
|
|
|
454
480
|
: prof
|
|
455
481
|
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
456
482
|
: "not logged in to Cloudflare: run `npx cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
457
|
-
|
|
483
|
+
let accts = normalizeAccounts(who.accounts);
|
|
484
|
+
if (!accts.length && prof) accts = accountsFromList(dir);
|
|
458
485
|
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
459
486
|
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
460
487
|
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
package/lib/main.mjs
CHANGED
|
@@ -41,7 +41,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
41
41
|
--hostname H moves it back); the old URL answers 301/410
|
|
42
42
|
--workers-dev-subdomain create the account's workers.dev subdomain if missing
|
|
43
43
|
--cf-profile cf auth profile for a separate Cloudflare login
|
|
44
|
-
(npx cf auth create NAME --no-browser); saved, used by every cf call
|
|
44
|
+
(npx cf auth create NAME --no-browser); saved, used by every cf call.
|
|
45
|
+
When whoami lists no accounts, init reads cf accounts list
|
|
45
46
|
--d1-name D1 database to create or reuse (default: worker name)
|
|
46
47
|
--pairing-approval who approves device-flow pairing requests (default human:
|
|
47
48
|
the owner token via \`pair approve\`, or the approval password
|
package/package.json
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- A wake is one peer's conversation. Two peers can use the same context id without sharing a row.
|
|
2
|
+
CREATE TABLE wakes_peer (
|
|
3
|
+
context_id TEXT NOT NULL,
|
|
4
|
+
peer TEXT NOT NULL DEFAULT '',
|
|
5
|
+
last_sent_ms INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
pending_json TEXT,
|
|
7
|
+
pending_since_ms INTEGER,
|
|
8
|
+
PRIMARY KEY (context_id, peer)
|
|
9
|
+
);
|
|
10
|
+
INSERT INTO wakes_peer (context_id, peer, last_sent_ms, pending_json, pending_since_ms)
|
|
11
|
+
SELECT
|
|
12
|
+
w.context_id,
|
|
13
|
+
COALESCE(
|
|
14
|
+
NULLIF(json_extract(w.pending_json, '$.from'), ''),
|
|
15
|
+
(SELECT t.peer FROM tasks t WHERE t.context_id = w.context_id AND t.peer IS NOT NULL AND t.peer != '' ORDER BY t.created_at, t.id LIMIT 1),
|
|
16
|
+
(SELECT h.peer FROM history h WHERE h.context_id = w.context_id AND h.peer IS NOT NULL AND h.peer != '' ORDER BY h.seq LIMIT 1),
|
|
17
|
+
''
|
|
18
|
+
),
|
|
19
|
+
w.last_sent_ms,
|
|
20
|
+
w.pending_json,
|
|
21
|
+
w.pending_since_ms
|
|
22
|
+
FROM wakes w;
|
|
23
|
+
DROP TABLE wakes;
|
|
24
|
+
ALTER TABLE wakes_peer RENAME TO wakes;
|
|
25
|
+
CREATE INDEX IF NOT EXISTS history_peer_ctx ON history(peer, context_id, seq);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- One owner per context id. The first peer to insert the row keeps it (ON CONFLICT DO NOTHING).
|
|
2
|
+
CREATE TABLE conversation_owners (
|
|
3
|
+
context_id TEXT NOT NULL PRIMARY KEY,
|
|
4
|
+
peer TEXT NOT NULL,
|
|
5
|
+
created_at TEXT NOT NULL
|
|
6
|
+
);
|
|
7
|
+
INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
8
|
+
SELECT t.context_id, t.peer, t.created_at
|
|
9
|
+
FROM tasks t
|
|
10
|
+
WHERE t.peer IS NOT NULL AND t.peer != ''
|
|
11
|
+
AND NOT EXISTS (
|
|
12
|
+
SELECT 1 FROM tasks t2
|
|
13
|
+
WHERE t2.context_id = t.context_id AND t2.peer IS NOT NULL AND t2.peer != ''
|
|
14
|
+
AND (t2.created_at < t.created_at OR (t2.created_at = t.created_at AND t2.id < t.id))
|
|
15
|
+
);
|
|
16
|
+
INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
17
|
+
SELECT h.context_id, h.peer, h.ts
|
|
18
|
+
FROM history h
|
|
19
|
+
WHERE h.peer IS NOT NULL AND h.peer != ''
|
|
20
|
+
AND NOT EXISTS (SELECT 1 FROM conversation_owners o WHERE o.context_id = h.context_id)
|
|
21
|
+
AND NOT EXISTS (
|
|
22
|
+
SELECT 1 FROM history h2
|
|
23
|
+
WHERE h2.context_id = h.context_id AND h2.peer IS NOT NULL AND h2.peer != '' AND h2.seq < h.seq
|
|
24
|
+
);
|
|
25
|
+
INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
26
|
+
SELECT o.context_id, o.peer, o.sent_at
|
|
27
|
+
FROM outbound o
|
|
28
|
+
WHERE o.peer IS NOT NULL AND o.peer != ''
|
|
29
|
+
AND NOT EXISTS (SELECT 1 FROM conversation_owners c WHERE c.context_id = o.context_id)
|
|
30
|
+
AND NOT EXISTS (
|
|
31
|
+
SELECT 1 FROM outbound o2
|
|
32
|
+
WHERE o2.context_id = o.context_id AND o2.peer IS NOT NULL AND o2.peer != ''
|
|
33
|
+
AND (o2.sent_at < o.sent_at OR (o2.sent_at = o.sent_at AND o2.task_id < o.task_id))
|
|
34
|
+
);
|
package/worker/src/export.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const EXPORT_TABLES = [
|
|
|
16
16
|
"peers", "tasks", "messages", "history", "outbound", "wakes",
|
|
17
17
|
"rate", "wake_budget", "device_requests", "pairing_rate", "settings",
|
|
18
18
|
"facade_owners", "oauth_clients", "oauth_codes", "mcp_grants",
|
|
19
|
-
"outbound_peers", "oauth_client_metadata", "oidc_txns",
|
|
19
|
+
"outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners",
|
|
20
20
|
] as const;
|
|
21
21
|
|
|
22
22
|
export type ExportTable = (typeof EXPORT_TABLES)[number];
|
package/worker/src/index.ts
CHANGED
|
@@ -67,6 +67,49 @@ function histStmt(ctx: TenantContext, contextId: string, e: Json) {
|
|
|
67
67
|
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Claim an unused context for this peer. `conversation_owners.context_id` is unique, and the insert does nothing
|
|
71
|
+
* when another peer already holds it or already has a task, history row, or outbound task on that id. */
|
|
72
|
+
function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
|
|
73
|
+
return ctx.DB.prepare(
|
|
74
|
+
`INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
75
|
+
SELECT ?1, ?2, ?3
|
|
76
|
+
WHERE NOT EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer != ?2)
|
|
77
|
+
AND NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
78
|
+
AND NOT EXISTS (SELECT 1 FROM history WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
79
|
+
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
80
|
+
ON CONFLICT(context_id) DO NOTHING`,
|
|
81
|
+
).bind(contextId, peer, ts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A new task is stored only when this peer owns the context. Runs in the same batch as `claimContextStmt`. */
|
|
85
|
+
function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
|
|
86
|
+
return ctx.DB.prepare(
|
|
87
|
+
`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
|
|
88
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
|
|
89
|
+
WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
|
|
91
|
+
WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3`,
|
|
92
|
+
).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
|
|
93
|
+
JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
|
|
97
|
+
return ctx.DB.prepare(
|
|
98
|
+
`INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
|
|
99
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
|
100
|
+
WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?3) = ?7`,
|
|
101
|
+
).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner: string) {
|
|
105
|
+
return ctx.DB.prepare(
|
|
106
|
+
`INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
|
|
107
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
|
|
108
|
+
WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?1) = ?11`,
|
|
109
|
+
).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
|
|
110
|
+
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
|
|
111
|
+
}
|
|
112
|
+
|
|
70
113
|
// ------------------------------------------------------------------ self-aware fetch (a Worker cannot reliably fetch its own custom domain)
|
|
71
114
|
/**
|
|
72
115
|
* Another tenant on this deployment's domain. A public fetch of that hostname returns 522, so the call
|
|
@@ -118,7 +161,7 @@ async function doFetch(ctx: TenantContext, ectx: ExecutionContext, url: string,
|
|
|
118
161
|
return fetch(url, { ...init, signal: AbortSignal.timeout(15000) });
|
|
119
162
|
}
|
|
120
163
|
|
|
121
|
-
// ------------------------------------------------------------------ wake webhook (presets), debounced per context via D1
|
|
164
|
+
// ------------------------------------------------------------------ wake webhook (presets), debounced per peer and context via D1
|
|
122
165
|
const preset = (ctx: TenantContext) => (ctx.WAKE_PRESET || "generic").trim();
|
|
123
166
|
|
|
124
167
|
function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
@@ -207,6 +250,21 @@ const WAKE_RETRIES = 3;
|
|
|
207
250
|
* flush it once Retry-After (30 s - 1 h) has passed, on the next request to the Worker or the optional cron. At most
|
|
208
251
|
* WAKE_RETRIES times per wake; a newer message for the conversation merges in and starts over. Pairing and test wakes
|
|
209
252
|
* are not retried (a pairing code lasts 60 minutes). */
|
|
253
|
+
function pendingWakePeer(pending: string | null | undefined): string {
|
|
254
|
+
if (!pending) return "";
|
|
255
|
+
try {
|
|
256
|
+
const from = JSON.parse(pending)?.from;
|
|
257
|
+
return typeof from === "string" ? from : "";
|
|
258
|
+
} catch { return ""; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The wake row for this peer's conversation. A row stored before peer was part of the key has peer ''. */
|
|
262
|
+
async function loadWake(ctx: TenantContext, contextId: string, peer: string): Promise<Json | null> {
|
|
263
|
+
const exact: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
|
|
264
|
+
if (exact || !peer) return exact;
|
|
265
|
+
return ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ''").bind(contextId).first();
|
|
266
|
+
}
|
|
267
|
+
|
|
210
268
|
async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
|
|
211
269
|
if (payload.kind !== "inbound") return;
|
|
212
270
|
const attempts = (Number(payload.attempts) || 0) + 1;
|
|
@@ -214,16 +272,18 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
|
|
|
214
272
|
const secs = Number(retryAfter);
|
|
215
273
|
const waitMs = Math.min(3600, Math.max(30, Number.isFinite(secs) && secs > 0 ? secs : 60)) * 1000;
|
|
216
274
|
const now = Date.now(), contextId = payload.contextId;
|
|
217
|
-
const
|
|
275
|
+
const peer = typeof payload.from === "string" ? payload.from : "";
|
|
276
|
+
const cur: Json = await loadWake(ctx, contextId, peer);
|
|
218
277
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
219
278
|
const merged = { ...payload, attempts, taskIds: [...new Set([...(prev?.taskIds || []), ...(payload.taskIds || [])])] };
|
|
220
279
|
// flushWake and nextCronAt wait until last_sent_ms + debounce. Store Retry-After minus that debounce so the
|
|
221
280
|
// deadline is Retry-After itself, including when the debounce is longer than Retry-After. Do not keep the
|
|
222
281
|
// previous last_sent_ms: that clock is the send we just made, and keeping it would wait out the debounce.
|
|
223
282
|
const due = now + waitMs - debounceMs(ctx);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
283
|
+
const rowPeer = cur && (cur.peer === peer || cur.peer === "" || cur.peer == null) ? (cur.peer ?? "") : peer;
|
|
284
|
+
await ctx.DB.prepare(`INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?, ?)
|
|
285
|
+
ON CONFLICT(context_id, peer) DO UPDATE SET pending_json = excluded.pending_json, last_sent_ms = excluded.last_sent_ms,
|
|
286
|
+
pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms)`).bind(contextId, rowPeer === "" ? peer : rowPeer, due, JSON.stringify(merged), now).run();
|
|
227
287
|
log("wake_requeued", { contextId, retryInS: Math.round(waitMs / 1000), attempt: attempts });
|
|
228
288
|
// The row exists now. A hosted alarm has to be set from this turn: the request that started sendWake may already have returned.
|
|
229
289
|
await noteWakeWrite(ectx);
|
|
@@ -232,33 +292,45 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
|
|
|
232
292
|
async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, taskId: string, from: string, preview: string, kind = "inbound") {
|
|
233
293
|
const payload: Json = { contextId, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind };
|
|
234
294
|
const now = Date.now(), deb = debounceMs(ctx);
|
|
235
|
-
|
|
295
|
+
let row: Json = await loadWake(ctx, contextId, from);
|
|
296
|
+
// A pre-peer row (peer '') is this conversation when it names this peer, or names nobody yet.
|
|
297
|
+
// Another peer's legacy row stays put; this peer gets its own row.
|
|
298
|
+
if (row && row.peer === "" && from) {
|
|
299
|
+
const named = pendingWakePeer(row.pending_json);
|
|
300
|
+
if (!named || named === from) {
|
|
301
|
+
const moved = await ctx.DB.prepare("UPDATE wakes SET peer = ? WHERE context_id = ? AND peer = ''").bind(from, contextId).run();
|
|
302
|
+
if (changed(moved) === 1) row = { ...row, peer: from };
|
|
303
|
+
else row = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, from).first();
|
|
304
|
+
} else row = null;
|
|
305
|
+
}
|
|
306
|
+
const peer = row?.peer ?? from;
|
|
236
307
|
let claimed = false;
|
|
237
308
|
if (!row) {
|
|
238
|
-
const r = await ctx.DB.prepare("INSERT INTO wakes (context_id, last_sent_ms) VALUES (?, ?) ON CONFLICT DO NOTHING").bind(contextId, now).run();
|
|
309
|
+
const r = await ctx.DB.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms) VALUES (?, ?, ?) ON CONFLICT DO NOTHING").bind(contextId, peer, now).run();
|
|
239
310
|
claimed = r.meta.changes === 1;
|
|
240
311
|
} else if (!row.pending_json && now - row.last_sent_ms >= deb) {
|
|
241
|
-
const r = await ctx.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NULL")
|
|
242
|
-
.bind(now, contextId, row.last_sent_ms).run();
|
|
312
|
+
const r = await ctx.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NULL")
|
|
313
|
+
.bind(now, contextId, peer, row.last_sent_ms).run();
|
|
243
314
|
claimed = r.meta.changes === 1;
|
|
244
315
|
}
|
|
245
316
|
if (claimed && (await wakeBudgetOk(ctx))) { ectx.waitUntil(sendWake(ctx, ectx, payload)); return; }
|
|
246
317
|
// debounce window (or hourly cap reached): merge into the pending wake and flush it later
|
|
247
|
-
const cur: Json = await ctx
|
|
318
|
+
const cur: Json = await loadWake(ctx, contextId, from);
|
|
319
|
+
const curPeer = cur?.peer ?? peer;
|
|
248
320
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
249
321
|
payload.taskIds = [...new Set([...(prev?.taskIds || []), taskId])];
|
|
250
|
-
await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ?")
|
|
251
|
-
.bind(JSON.stringify(payload), now, contextId).run();
|
|
322
|
+
await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ? AND peer = ?")
|
|
323
|
+
.bind(JSON.stringify(payload), now, contextId, curPeer).run();
|
|
252
324
|
// The pending row exists now. A hosted alarm is set in this turn, including when the delay is too long for waitUntil.
|
|
253
325
|
await noteWakeWrite(ectx);
|
|
254
326
|
const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
|
|
255
327
|
// waitUntil can outlive the response by ~30s. Longer windows are flushed by a later request, the optional
|
|
256
328
|
// single-inbox cron, or the registered alarm (set above).
|
|
257
|
-
if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId)));
|
|
329
|
+
if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId, curPeer)));
|
|
258
330
|
}
|
|
259
331
|
|
|
260
|
-
async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId: string) {
|
|
261
|
-
const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(contextId).first();
|
|
332
|
+
async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, peer: string) {
|
|
333
|
+
const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
|
|
262
334
|
if (!row || !row.pending_json) return;
|
|
263
335
|
const now = Date.now();
|
|
264
336
|
if (now - row.last_sent_ms < debounceMs(ctx)) return;
|
|
@@ -268,16 +340,16 @@ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId:
|
|
|
268
340
|
if ((b?.count ?? 0) >= maxPerHour(ctx)) return; // keep pending until the next hour
|
|
269
341
|
}
|
|
270
342
|
const r = await ctx.DB.prepare(
|
|
271
|
-
"UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
|
|
272
|
-
).bind(now, contextId, row.last_sent_ms).run();
|
|
343
|
+
"UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
|
|
344
|
+
).bind(now, contextId, peer, row.last_sent_ms).run();
|
|
273
345
|
if (r.meta.changes === 1 && (await wakeBudgetOk(ctx))) await sendWake(ctx, ectx, JSON.parse(row.pending_json));
|
|
274
346
|
}
|
|
275
347
|
|
|
276
348
|
/** Flush every pending wake whose debounce window has passed (called opportunistically and from cron). */
|
|
277
349
|
async function flushDue(ctx: TenantContext, ectx: ExecutionContext) {
|
|
278
|
-
const rows = await ctx.DB.prepare("SELECT context_id FROM wakes WHERE pending_json IS NOT NULL AND last_sent_ms <= ? LIMIT 20")
|
|
350
|
+
const rows = await ctx.DB.prepare("SELECT context_id, peer FROM wakes WHERE pending_json IS NOT NULL AND last_sent_ms <= ? LIMIT 20")
|
|
279
351
|
.bind(Date.now() - debounceMs(ctx)).all();
|
|
280
|
-
for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id);
|
|
352
|
+
for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id, typeof r.peer === "string" ? r.peer : "");
|
|
281
353
|
}
|
|
282
354
|
|
|
283
355
|
/**
|
|
@@ -569,6 +641,31 @@ const V03: Record<string, string> = {
|
|
|
569
641
|
"message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
|
|
570
642
|
"tasks/pushNotificationConfig/get": "pushget", "message/stream": "stream", "tasks/resubscribe": "stream",
|
|
571
643
|
};
|
|
644
|
+
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
645
|
+
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
646
|
+
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
647
|
+
const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
|
|
648
|
+
|
|
649
|
+
/** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
|
|
650
|
+
* shape, so a 0.3 client that omits the header stays on 0.3 and a headerless `SendMessage` stays 1.0. */
|
|
651
|
+
function declaredA2AVersion(req: Request): { raw: string; version: string | null } {
|
|
652
|
+
let raw = (req.headers.get("a2a-version") || "").trim();
|
|
653
|
+
if (!raw) {
|
|
654
|
+
try { raw = (new URL(req.url).searchParams.get("A2A-Version") || "").trim(); } catch { raw = ""; }
|
|
655
|
+
}
|
|
656
|
+
if (!raw) return { raw: "", version: null };
|
|
657
|
+
if (/^(1\.0(\.\d+)?|1)$/.test(raw)) return { raw, version: "1.0" };
|
|
658
|
+
if (/^0\.3(\.\d+)?$/.test(raw)) return { raw, version: "0.3" };
|
|
659
|
+
return { raw, version: null };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function rpcOperation(method: string, proxy: boolean): { op: string; version: string } | null {
|
|
663
|
+
if (V1[method]) return { op: V1[method], version: "1.0" };
|
|
664
|
+
if (V03[method]) return { op: V03[method], version: "0.3" };
|
|
665
|
+
if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
|
|
666
|
+
if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
572
669
|
|
|
573
670
|
async function ownedTask(ctx: TenantContext, id: unknown, label: string): Promise<Json> {
|
|
574
671
|
if (typeof id !== "string" || !A.ID_RE.test(id)) throw new RpcError(-32001, "Task not found");
|
|
@@ -577,6 +674,10 @@ async function ownedTask(ctx: TenantContext, id: unknown, label: string): Promis
|
|
|
577
674
|
return t;
|
|
578
675
|
}
|
|
579
676
|
|
|
677
|
+
/** A contextId this caller does not get to use. The same sentence is returned for an id the façade has no
|
|
678
|
+
* record of. It does not say whether the id exists, or which peer holds it. */
|
|
679
|
+
const UNKNOWN_CONTEXT = "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)";
|
|
680
|
+
|
|
580
681
|
function normPushCfg(cfg: Json, version: string): Json {
|
|
581
682
|
if (!cfg || typeof cfg !== "object" || typeof cfg.url !== "string") throw new RpcError(-32602, "push notification config needs url");
|
|
582
683
|
const out: Json = { id: typeof cfg.id === "string" && cfg.id ? cfg.id.slice(0, 128) : A.newId(), url: cfg.url, _version: version };
|
|
@@ -609,6 +710,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
609
710
|
}
|
|
610
711
|
const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
|
|
611
712
|
const ts = A.nowIso();
|
|
713
|
+
// A client-provided id nobody owns is still this peer's new conversation (single-peer and outbound send
|
|
714
|
+
// keep passing one). An id another peer already has is not. A2A forbids minting a replacement id when the
|
|
715
|
+
// client id is refused, so this is the same error the façade returns for an unknown context, not a new id.
|
|
716
|
+
// The claim and the first task are one batch: a second peer cannot pass a read and then write its own task.
|
|
717
|
+
const creating = !task;
|
|
612
718
|
if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
|
|
613
719
|
msg.contextId = contextId; msg.taskId = task.id;
|
|
614
720
|
task.history.push(msg);
|
|
@@ -616,10 +722,23 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
616
722
|
if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
|
|
617
723
|
if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
|
|
618
724
|
const preview = A.textOf(msg);
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
725
|
+
if (creating) {
|
|
726
|
+
const stored = await ctx.DB.batch([
|
|
727
|
+
claimContextStmt(ctx, contextId, label, ts),
|
|
728
|
+
saveTaskIfOwnerStmt(ctx, task),
|
|
729
|
+
msgIfOwnerStmt(ctx, task, msg),
|
|
730
|
+
histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
|
|
731
|
+
ctx.DB.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").bind(contextId),
|
|
732
|
+
]);
|
|
733
|
+
const owner = (stored[4]?.results?.[0] as Json | undefined)?.peer;
|
|
734
|
+
const wrote = stored[1]?.meta?.changes ?? 0;
|
|
735
|
+
if (owner !== label || wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
|
|
736
|
+
} else {
|
|
737
|
+
await ctx.DB.batch([
|
|
738
|
+
saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
|
|
739
|
+
histStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }),
|
|
740
|
+
]);
|
|
741
|
+
}
|
|
623
742
|
log("message_received", { peer: label, contextId, taskId: task.id, chars: preview.length, version });
|
|
624
743
|
await wake(ctx, ectx, contextId, task.id, label, preview);
|
|
625
744
|
const pt = A.publicTask(task, version, conf.historyLength);
|
|
@@ -713,15 +832,14 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
|
|
|
713
832
|
const err = (code: number, message: string) => json({ jsonrpc: "2.0", id: rid, error: { code, message } });
|
|
714
833
|
if (!rq || typeof rq !== "object" || rq.jsonrpc !== "2.0" || typeof rq.method !== "string") return err(-32600, "Invalid Request");
|
|
715
834
|
const method: string = rq.method;
|
|
716
|
-
let op: string, version: string;
|
|
717
835
|
const proxy = proxyMode(ctx);
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
836
|
+
const declared = declaredA2AVersion(req);
|
|
837
|
+
if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
|
|
838
|
+
if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
|
|
839
|
+
const found = rpcOperation(method, proxy);
|
|
840
|
+
if (!found) return err(-32601, "Method not found");
|
|
841
|
+
const version = declared.version || found.version;
|
|
842
|
+
const op = found.op;
|
|
725
843
|
if (op === "stream") return err(-32004, proxy ? "Streaming is not supported through this façade yet" : "Streaming is not supported");
|
|
726
844
|
const params = rq.params ?? {};
|
|
727
845
|
if (typeof params !== "object" || Array.isArray(params)) return err(-32602, "Invalid params");
|
|
@@ -796,10 +914,6 @@ async function verifyUpstream(ctx: TenantContext): Promise<Json> {
|
|
|
796
914
|
return { ok: verdict.reason === "reachable", ...verdict, sent, method: VERIFY_METHOD, upstreamWantsBearer: F.upstreamAuth(up.card).bearer };
|
|
797
915
|
}
|
|
798
916
|
|
|
799
|
-
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
800
|
-
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
801
|
-
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
802
|
-
|
|
803
917
|
async function ownerOf(ctx: TenantContext, kind: "task" | "context", id: string): Promise<string | null> {
|
|
804
918
|
const r: Json = await ctx.DB.prepare("SELECT peer FROM facade_owners WHERE kind = ? AND id = ?").bind(kind, id).first();
|
|
805
919
|
return r ? r.peer : null;
|
|
@@ -837,7 +951,7 @@ async function proxyRpc(req: Request, ctx: TenantContext, c: { raw: string; rid:
|
|
|
837
951
|
// may still be a live upstream context (another peer's, or one opened outside the façade), and the façade can't
|
|
838
952
|
// tell, so client-chosen context ids are refused; omit contextId to start a conversation and reuse the returned one.
|
|
839
953
|
if ((await ownerOf(ctx, "context", m.contextId)) !== c.label)
|
|
840
|
-
return err(-32602,
|
|
954
|
+
return err(-32602, UNKNOWN_CONTEXT);
|
|
841
955
|
}
|
|
842
956
|
} else {
|
|
843
957
|
const tid = F.taskIdOf(c.op, c.params);
|
|
@@ -847,8 +961,9 @@ async function proxyRpc(req: Request, ctx: TenantContext, c: { raw: string; rid:
|
|
|
847
961
|
// public-looking name to a private address), so push configs never reach it. Follow-up: a relay through the Worker.
|
|
848
962
|
if (F.wantsPush(c.op, c.params)) return err(-32003, "Push notifications are not supported through this façade; poll GetTask with the task id instead");
|
|
849
963
|
const headers = upstreamHeaders(ctx, ep, { "content-type": "application/json", accept: "application/json", "x-a2a-peer": c.label });
|
|
850
|
-
|
|
851
|
-
|
|
964
|
+
// Declared version only (header, else query). A headerless call stays headerless, so the upstream still chooses from the method name.
|
|
965
|
+
const declared = declaredA2AVersion(req);
|
|
966
|
+
if (declared.version) headers["a2a-version"] = declared.version;
|
|
852
967
|
let r: Response;
|
|
853
968
|
try {
|
|
854
969
|
r = await fetch(ep, { method: "POST", headers, body: c.raw, redirect: "manual", signal: AbortSignal.timeout(90000) });
|
|
@@ -1099,22 +1214,25 @@ async function ackPendingWakes(ctx: TenantContext, seen: { taskId: string; conte
|
|
|
1099
1214
|
byContext.set(row.contextId, ids);
|
|
1100
1215
|
}
|
|
1101
1216
|
for (const [contextId, seenIds] of byContext) {
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1217
|
+
const rows = await ctx.DB.prepare("SELECT peer, pending_json FROM wakes WHERE context_id = ? AND pending_json IS NOT NULL").bind(contextId).all();
|
|
1218
|
+
for (const row of (rows.results || []) as Json[]) {
|
|
1219
|
+
if (typeof row?.pending_json !== "string") continue;
|
|
1220
|
+
let payload: Json;
|
|
1221
|
+
try { payload = JSON.parse(row.pending_json); } catch { continue; }
|
|
1222
|
+
const wakeKind = typeof payload?.kind === "string" && payload.kind ? payload.kind : "inbound";
|
|
1223
|
+
if (wakeKind !== kind) continue;
|
|
1224
|
+
if (typeof payload?.contextId === "string" && payload.contextId !== contextId) continue;
|
|
1225
|
+
const ids = wakeTaskIds(payload);
|
|
1226
|
+
if (!ids.some((id) => seenIds.has(id))) continue;
|
|
1227
|
+
const kept = ids.filter((id) => !seenIds.has(id));
|
|
1228
|
+
const peer = typeof row.peer === "string" ? row.peer : "";
|
|
1229
|
+
if (!kept.length) {
|
|
1230
|
+
await ctx.DB.prepare("UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL WHERE context_id = ? AND peer = ? AND pending_json = ?").bind(contextId, peer, row.pending_json).run();
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
|
|
1234
|
+
await ctx.DB.prepare("UPDATE wakes SET pending_json = ? WHERE context_id = ? AND peer = ? AND pending_json = ?").bind(JSON.stringify(next), contextId, peer, row.pending_json).run();
|
|
1115
1235
|
}
|
|
1116
|
-
const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
|
|
1117
|
-
await ctx.DB.prepare("UPDATE wakes SET pending_json = ? WHERE context_id = ? AND pending_json = ?").bind(JSON.stringify(next), contextId, row.pending_json).run();
|
|
1118
1236
|
}
|
|
1119
1237
|
}
|
|
1120
1238
|
|
|
@@ -1251,7 +1369,19 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1251
1369
|
}
|
|
1252
1370
|
if (seg[0] === "history" && seg[1] && m === "GET") {
|
|
1253
1371
|
const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
|
|
1254
|
-
|
|
1372
|
+
// Rows for this conversation: this context, and a peer that owns it (a task or an outbound task).
|
|
1373
|
+
// A null peer is a local note. When nothing owns the id yet, the rows that name it are the conversation.
|
|
1374
|
+
const rows = await ctx.DB.prepare(`SELECT * FROM (
|
|
1375
|
+
SELECT * FROM history WHERE context_id = ? AND (
|
|
1376
|
+
peer IS NULL OR peer = ''
|
|
1377
|
+
OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
1378
|
+
OR peer IN (SELECT peer FROM outbound WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
1379
|
+
OR (
|
|
1380
|
+
NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?)
|
|
1381
|
+
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
|
|
1382
|
+
)
|
|
1383
|
+
) ORDER BY seq DESC LIMIT ?
|
|
1384
|
+
) ORDER BY seq`).bind(seg[1], seg[1], seg[1], seg[1], seg[1], n).all();
|
|
1255
1385
|
return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
|
|
1256
1386
|
}
|
|
1257
1387
|
if (seg[0] === "history" && !seg[1] && m === "POST") {
|
|
@@ -1413,6 +1543,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1413
1543
|
ctx.DB.prepare("DELETE FROM history WHERE context_id = ?").bind(c),
|
|
1414
1544
|
ctx.DB.prepare("DELETE FROM outbound WHERE context_id = ?").bind(c),
|
|
1415
1545
|
ctx.DB.prepare("DELETE FROM wakes WHERE context_id = ?").bind(c),
|
|
1546
|
+
ctx.DB.prepare("DELETE FROM conversation_owners WHERE context_id = ?").bind(c),
|
|
1416
1547
|
]);
|
|
1417
1548
|
n += res.reduce((s, r) => s + (r.meta.changes || 0), 0);
|
|
1418
1549
|
}
|
package/worker/src/migrations.ts
CHANGED
|
@@ -9,6 +9,8 @@ import m0006 from "../migrations/0006_mcp.sql?raw";
|
|
|
9
9
|
import m0007 from "../migrations/0007_cimd.sql?raw";
|
|
10
10
|
import m0008 from "../migrations/0008_oidc_txns.sql?raw";
|
|
11
11
|
import m0009 from "../migrations/0009_outbound_seen.sql?raw";
|
|
12
|
+
import m0010 from "../migrations/0010_conversation_peer.sql?raw";
|
|
13
|
+
import m0011 from "../migrations/0011_conversation_owner.sql?raw";
|
|
12
14
|
import type { Migration } from "./storage.ts";
|
|
13
15
|
|
|
14
16
|
export const MIGRATIONS: Migration[] = [
|
|
@@ -21,4 +23,6 @@ export const MIGRATIONS: Migration[] = [
|
|
|
21
23
|
{ name: "0007_cimd.sql", sql: m0007 },
|
|
22
24
|
{ name: "0008_oidc_txns.sql", sql: m0008 },
|
|
23
25
|
{ name: "0009_outbound_seen.sql", sql: m0009 },
|
|
26
|
+
{ name: "0010_conversation_peer.sql", sql: m0010 },
|
|
27
|
+
{ name: "0011_conversation_owner.sql", sql: m0011 },
|
|
24
28
|
];
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.17";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// A peer must not post into another peer's conversation by reusing its contextId.
|
|
2
|
+
// Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker from "../src/index.ts";
|
|
6
|
+
import { d1 } from "./d1.ts";
|
|
7
|
+
|
|
8
|
+
const BASE = "https://agent.example.com";
|
|
9
|
+
const UNKNOWN = "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)";
|
|
10
|
+
|
|
11
|
+
function setup() {
|
|
12
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
13
|
+
const env: any = {
|
|
14
|
+
DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", WAKE_PRESET: "generic",
|
|
15
|
+
WAKE_DEBOUNCE_SECONDS: "3600", WAKE_WEBHOOK_URL: "https://hook.example.net/wake", WAKE_WEBHOOK_KEY: "hook-key",
|
|
16
|
+
};
|
|
17
|
+
const origFetch = globalThis.fetch;
|
|
18
|
+
globalThis.fetch = (async () => new Response("ok")) as typeof fetch;
|
|
19
|
+
const call = async (method: string, path: string, json?: unknown, auth = "Bearer owner-secret") => {
|
|
20
|
+
const headers: Record<string, string> = { authorization: auth, "content-type": "application/json", "cf-connecting-ip": "198.51.100.7" };
|
|
21
|
+
const pending: Promise<unknown>[] = [];
|
|
22
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }) as any,
|
|
23
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
24
|
+
await Promise.allSettled(pending);
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let data: any = text;
|
|
27
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* not json */ }
|
|
28
|
+
return { status: res.status, data, text };
|
|
29
|
+
};
|
|
30
|
+
const owner = (method: string, path: string, json?: unknown) => call(method, path, json);
|
|
31
|
+
const send = (token: string, text: string, contextId?: string, method = "message/send") => call("POST", "/", {
|
|
32
|
+
jsonrpc: "2.0", id: 1, method,
|
|
33
|
+
params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), ...(contextId ? { contextId } : {}), parts: [{ kind: "text", text }] } },
|
|
34
|
+
}, `Bearer ${token}`);
|
|
35
|
+
return { DB, owner, send, restore: () => { globalThis.fetch = origFetch; } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("another peer's contextId is refused without joining that conversation", async (t) => {
|
|
39
|
+
const s = setup();
|
|
40
|
+
t.after(s.restore);
|
|
41
|
+
const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
|
|
42
|
+
const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
|
|
43
|
+
assert.match(ada, /^a2aow_/);
|
|
44
|
+
assert.match(bob, /^a2aow_/);
|
|
45
|
+
|
|
46
|
+
const first = await s.send(ada, "y-one", "ctx-y");
|
|
47
|
+
assert.equal(first.status, 200, first.text);
|
|
48
|
+
assert.equal(first.data.result.contextId, "ctx-y");
|
|
49
|
+
const second = await s.send(ada, "y-two", "ctx-y");
|
|
50
|
+
assert.equal(second.status, 200, second.text);
|
|
51
|
+
assert.equal(second.data.result.contextId, "ctx-y");
|
|
52
|
+
|
|
53
|
+
const history = () => s.owner("GET", "/owner/history/ctx-y");
|
|
54
|
+
const beforeHist = await history();
|
|
55
|
+
assert.equal(beforeHist.status, 200, beforeHist.text);
|
|
56
|
+
const beforeTexts = (beforeHist.data as { text: string }[]).map((r) => r.text);
|
|
57
|
+
assert.deepEqual(beforeTexts, ["y-one", "y-two"]);
|
|
58
|
+
const wake = () => s.DB.db.prepare("SELECT context_id, peer, last_sent_ms, pending_json, pending_since_ms FROM wakes WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as {
|
|
59
|
+
pending_json: string; peer: string; last_sent_ms: number; pending_since_ms: number;
|
|
60
|
+
};
|
|
61
|
+
const beforeWake = wake();
|
|
62
|
+
assert.equal(beforeWake.peer, "ada");
|
|
63
|
+
const pending = JSON.parse(beforeWake.pending_json);
|
|
64
|
+
assert.equal(pending.from, "ada");
|
|
65
|
+
assert.equal(pending.preview, "y-two");
|
|
66
|
+
assert.equal(pending.taskIds.includes(second.data.result.id), true);
|
|
67
|
+
|
|
68
|
+
const foreign = await s.send(bob, "x-inject", "ctx-y");
|
|
69
|
+
const foreignV1 = await s.send(bob, "x-inject-v1", "ctx-y", "SendMessage");
|
|
70
|
+
assert.deepEqual(foreign.data.error, { code: -32602, message: UNKNOWN });
|
|
71
|
+
assert.deepEqual(foreignV1.data.error, foreign.data.error);
|
|
72
|
+
const body = JSON.stringify(foreign.data);
|
|
73
|
+
assert.equal(body.includes("ada"), false);
|
|
74
|
+
assert.equal(body.includes("ctx-y"), false);
|
|
75
|
+
assert.equal(body.includes("y-one"), false);
|
|
76
|
+
assert.equal(body.includes("y-two"), false);
|
|
77
|
+
assert.equal(body.includes("exists"), false);
|
|
78
|
+
assert.equal(body.includes(ada), false);
|
|
79
|
+
assert.equal(body.includes(bob), false);
|
|
80
|
+
|
|
81
|
+
const afterHist = await history();
|
|
82
|
+
assert.deepEqual(afterHist.data, beforeHist.data);
|
|
83
|
+
assert.deepEqual(wake(), beforeWake);
|
|
84
|
+
const db = s.DB.db;
|
|
85
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
|
|
86
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
|
|
87
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
|
|
88
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-y' AND message_json LIKE '%x-inject%'").get() as { n: number }).n, 0);
|
|
89
|
+
|
|
90
|
+
assert.equal((db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { peer: string }).peer, "ada");
|
|
91
|
+
|
|
92
|
+
const follow = await s.send(ada, "y-three", "ctx-y");
|
|
93
|
+
assert.equal(follow.status, 200, follow.text);
|
|
94
|
+
assert.equal(follow.data.result.contextId, "ctx-y");
|
|
95
|
+
const texts = ((await history()).data as { text: string; peer: string }[]).map((r) => r.text);
|
|
96
|
+
assert.deepEqual(texts, ["y-one", "y-two", "y-three"]);
|
|
97
|
+
const after = JSON.parse(wake().pending_json);
|
|
98
|
+
assert.equal(after.from, "ada");
|
|
99
|
+
assert.equal(after.preview, "y-three");
|
|
100
|
+
assert.equal(String(after.preview).includes("x-inject"), false);
|
|
101
|
+
assert.equal(after.taskIds.includes(follow.data.result.id), true);
|
|
102
|
+
assert.equal(after.taskIds.includes(second.data.result.id), true);
|
|
103
|
+
|
|
104
|
+
const own = await s.send(bob, "x-own", "ctx-x");
|
|
105
|
+
assert.equal(own.status, 200, own.text);
|
|
106
|
+
assert.equal(own.data.result.contextId, "ctx-x");
|
|
107
|
+
const again = await s.send(bob, "x-again", "ctx-x");
|
|
108
|
+
assert.equal(again.status, 200, again.text);
|
|
109
|
+
assert.equal(again.data.result.contextId, "ctx-x");
|
|
110
|
+
const bobHist = await s.owner("GET", "/owner/history/ctx-x");
|
|
111
|
+
assert.deepEqual((bobHist.data as { text: string }[]).map((r) => r.text), ["x-own", "x-again"]);
|
|
112
|
+
assert.deepEqual(((await history()).data as { text: string }[]).map((r) => r.text), ["y-one", "y-two", "y-three"]);
|
|
113
|
+
assert.equal(JSON.parse(wake().pending_json).from, "ada");
|
|
114
|
+
assert.equal(JSON.parse(wake().pending_json).preview, "y-three");
|
|
115
|
+
assert.equal((db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-x'").get() as { peer: string }).peer, "bob");
|
|
116
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { n: number }).n, 1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("two peers cannot both claim one unused contextId", async (t) => {
|
|
120
|
+
const s = setup();
|
|
121
|
+
t.after(s.restore);
|
|
122
|
+
const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
|
|
123
|
+
const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
|
|
124
|
+
let arrived = 0;
|
|
125
|
+
let openGate = () => {};
|
|
126
|
+
const gate = new Promise<void>((resolve) => { openGate = resolve; });
|
|
127
|
+
const orig = s.DB.batch.bind(s.DB);
|
|
128
|
+
s.DB.batch = async (stmts: { __sql?: string }[]) => {
|
|
129
|
+
const claim = stmts.some((st) => typeof st.__sql === "string" && st.__sql.includes("INSERT INTO conversation_owners"));
|
|
130
|
+
if (!claim || arrived >= 2) return orig(stmts);
|
|
131
|
+
arrived++;
|
|
132
|
+
if (arrived === 2) openGate();
|
|
133
|
+
await gate;
|
|
134
|
+
return orig(stmts);
|
|
135
|
+
};
|
|
136
|
+
const [left, right] = await Promise.all([
|
|
137
|
+
s.send(ada, "from-ada", "ctx-race"),
|
|
138
|
+
s.send(bob, "from-bob", "ctx-race"),
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(arrived, 2);
|
|
141
|
+
const ok = [left, right].filter((r) => r.status === 200 && !r.data.error);
|
|
142
|
+
const denied = [left, right].filter((r) => r.data?.error);
|
|
143
|
+
assert.equal(ok.length, 1, JSON.stringify([left.data, right.data]));
|
|
144
|
+
assert.equal(denied.length, 1);
|
|
145
|
+
assert.deepEqual(denied[0].data.error, { code: -32602, message: UNKNOWN });
|
|
146
|
+
assert.equal(ok[0].data.result.contextId, "ctx-race");
|
|
147
|
+
const body = JSON.stringify(denied[0].data);
|
|
148
|
+
for (const secret of ["ada", "bob", "ctx-race", "from-ada", "from-bob", "exists", ada, bob]) assert.equal(body.includes(secret), false, secret);
|
|
149
|
+
|
|
150
|
+
const db = s.DB.db;
|
|
151
|
+
const owners = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-race'").all() as { peer: string }[];
|
|
152
|
+
assert.equal(owners.length, 1);
|
|
153
|
+
const winner = owners[0].peer;
|
|
154
|
+
const loser = winner === "ada" ? "bob" : "ada";
|
|
155
|
+
assert.equal(winner === "ada" || winner === "bob", true);
|
|
156
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race'").get() as { n: number }).n, 1);
|
|
157
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
|
|
158
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
|
|
159
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-race' AND message_json LIKE ?").get(`%from-${loser}%`) as { n: number }).n, 0);
|
|
160
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
|
|
161
|
+
|
|
162
|
+
const follow = await s.send(winner === "ada" ? ada : bob, "follow", "ctx-race");
|
|
163
|
+
assert.equal(follow.status, 200, follow.text);
|
|
164
|
+
assert.equal(follow.data.result.contextId, "ctx-race");
|
|
165
|
+
const hist = await s.owner("GET", "/owner/history/ctx-race");
|
|
166
|
+
assert.deepEqual((hist.data as { text: string; peer: string }[]).map((r) => r.text), [winner === "ada" ? "from-ada" : "from-bob", "follow"]);
|
|
167
|
+
assert.equal((hist.data as { peer: string }[]).every((r) => r.peer === winner), true);
|
|
168
|
+
const again = await s.send(loser === "ada" ? ada : bob, "still-no", "ctx-race");
|
|
169
|
+
assert.deepEqual(again.data.error, { code: -32602, message: UNKNOWN });
|
|
170
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
|
|
171
|
+
});
|
|
@@ -268,6 +268,54 @@ test("proxy mode: JSON-RPC is forwarded with the façade's credential, never the
|
|
|
268
268
|
assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM tasks").get() as any).n, 0);
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
test("proxy mode: a declared A2A-Version query is forwarded as the header; a headerless call stays headerless", async (t) => {
|
|
272
|
+
let n = 0;
|
|
273
|
+
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-ver.json" }, async (url, init) => {
|
|
274
|
+
if (url.endsWith(".json")) return Response.json(tailnetCard());
|
|
275
|
+
const rq = JSON.parse(init.body);
|
|
276
|
+
const hv = init.headers?.["a2a-version"];
|
|
277
|
+
const v1 = hv ? hv.startsWith("1") : !String(rq.method).includes("/");
|
|
278
|
+
const id = `up-task-${++n}`, contextId = `up-ctx-${n}`;
|
|
279
|
+
const result = v1
|
|
280
|
+
? { task: { id, contextId, status: { state: "TASK_STATE_SUBMITTED" }, history: [{ role: "ROLE_USER", parts: [{ text: "hi" }] }] } }
|
|
281
|
+
: { kind: "task", id, contextId, status: { state: "submitted" }, history: [{ kind: "message", role: "user", parts: [{ kind: "text", text: "hi" }] }] };
|
|
282
|
+
return Response.json({ jsonrpc: "2.0", id: rq.id, result });
|
|
283
|
+
});
|
|
284
|
+
t.after(s.restore);
|
|
285
|
+
const tok = await s.issue("ada");
|
|
286
|
+
const msg = { message: { role: "user", messageId: "m-ver", parts: [{ kind: "text", text: "hi" }] } };
|
|
287
|
+
const post = (path: string, method: string, headers: Record<string, string> = {}) =>
|
|
288
|
+
s.call("POST", path, { token: tok, json: { jsonrpc: "2.0", id: 1, method, params: msg }, headers });
|
|
289
|
+
const last = () => s.calls.filter((c) => c.url === UPSTREAM).at(-1);
|
|
290
|
+
|
|
291
|
+
const q = await post("/?A2A-Version=0.3", "SendMessage");
|
|
292
|
+
assert.equal(q.status, 200, q.text);
|
|
293
|
+
assert.equal(last().url, UPSTREAM);
|
|
294
|
+
assert.equal(last().headers["a2a-version"], "0.3");
|
|
295
|
+
assert.equal(q.data.result.kind, "task");
|
|
296
|
+
assert.equal(q.data.result.status.state, "submitted");
|
|
297
|
+
assert.equal(q.data.result.task, undefined);
|
|
298
|
+
|
|
299
|
+
const bare = await post("/", "SendMessage");
|
|
300
|
+
assert.equal(last().headers["a2a-version"], undefined);
|
|
301
|
+
assert.equal(bare.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
302
|
+
assert.equal(bare.data.result.kind, undefined);
|
|
303
|
+
|
|
304
|
+
const bare03 = await post("/", "message/send");
|
|
305
|
+
assert.equal(last().headers["a2a-version"], undefined);
|
|
306
|
+
assert.equal(bare03.data.result.kind, "task");
|
|
307
|
+
assert.equal(bare03.data.result.status.state, "submitted");
|
|
308
|
+
|
|
309
|
+
const wins = await post("/?A2A-Version=0.3", "SendMessage", { "a2a-version": "1.0" });
|
|
310
|
+
assert.equal(last().headers["a2a-version"], "1.0");
|
|
311
|
+
assert.equal(wins.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
312
|
+
|
|
313
|
+
const patch = await post("/?A2A-Version=0.3.0", "SendMessage");
|
|
314
|
+
assert.equal(last().headers["a2a-version"], "0.3");
|
|
315
|
+
assert.equal(patch.data.result.kind, "task");
|
|
316
|
+
assert.equal(patch.data.result.status.state, "submitted");
|
|
317
|
+
});
|
|
318
|
+
|
|
271
319
|
test("proxy mode: peers sharing the upstream identity can't touch each other's tasks or contexts", async (t) => {
|
|
272
320
|
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-c.json" });
|
|
273
321
|
t.after(s.restore);
|
|
@@ -309,7 +357,14 @@ test("proxy mode: peers sharing the upstream identity can't touch each other's t
|
|
|
309
357
|
// ListTasks would list every peer's tasks; streaming is not proxied yet
|
|
310
358
|
assert.equal((await s.rpc(a, "ListTasks", {})).data.error?.code, -32004);
|
|
311
359
|
assert.equal((await s.rpc(a, "SendStreamingMessage", { message: { role: "ROLE_USER", parts: [{ text: "x" }] } })).data.error?.code, -32004);
|
|
312
|
-
|
|
360
|
+
const nCard = s.calls.filter((c) => c.url === UPSTREAM).length;
|
|
361
|
+
const ext = await s.rpc(a, "GetExtendedAgentCard", {});
|
|
362
|
+
assert.equal(ext.status, 200);
|
|
363
|
+
assert.equal(ext.data.error?.code, -32007);
|
|
364
|
+
assert.equal(ext.data.error?.message, "Extended agent card is not configured");
|
|
365
|
+
const ext03 = await s.call("POST", "/", { token: a, json: { jsonrpc: "2.0", id: 1, method: "agent/getAuthenticatedExtendedCard", params: {} }, headers: { "a2a-version": "0.3" } });
|
|
366
|
+
assert.equal(ext03.data.error?.code, -32007);
|
|
367
|
+
assert.equal(s.calls.filter((c) => c.url === UPSTREAM).length, nCard, "the extended card stays on the façade");
|
|
313
368
|
});
|
|
314
369
|
|
|
315
370
|
test("proxy mode: push configs never reach the upstream (it would call them from inside the private network)", async (t) => {
|
|
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
|
|
|
57
57
|
test("fresh database: every migration applies in order; a second run applies nothing", () => {
|
|
58
58
|
const db = new DatabaseSync(":memory:");
|
|
59
59
|
assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
|
|
60
|
-
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns"]) assert.ok(tables(db).includes(t), t);
|
|
60
|
+
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners"]) assert.ok(tables(db).includes(t), t);
|
|
61
61
|
assert.ok(columns(db, "peers").includes("source"));
|
|
62
62
|
assert.ok(columns(db, "device_requests").includes("replaces_label"));
|
|
63
63
|
assert.ok(columns(db, "device_requests").includes("replaces_hash"));
|
|
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
67
67
|
test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
|
|
68
68
|
const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
|
|
69
69
|
db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
|
|
70
|
-
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
|
|
70
|
+
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql"]);
|
|
71
71
|
assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
|
|
75
75
|
const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
|
|
76
76
|
db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
|
|
77
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
|
|
78
|
-
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
|
|
77
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql"]);
|
|
78
|
+
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql"]);
|
|
79
79
|
assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
|
|
80
80
|
});
|
|
81
81
|
|
|
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
|
|
|
107
107
|
assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
|
|
108
108
|
assert.equal(wakes.length, 0);
|
|
109
109
|
|
|
110
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
|
|
110
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql"]);
|
|
111
111
|
assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
|
|
112
112
|
assert.deepEqual(applyMigrations(db), []);
|
|
113
113
|
|
|
@@ -122,3 +122,38 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
|
|
|
122
122
|
globalThis.fetch = origFetch;
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
test("0010 keys an existing wake by the peer in its payload and allows a second peer", () => {
|
|
127
|
+
const prior = [...FILES].sort(byNumber).filter((f) => f !== "0010_conversation_peer.sql");
|
|
128
|
+
const db = existingDb(prior.map(sql), prior);
|
|
129
|
+
db.prepare(`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
|
|
130
|
+
VALUES ('t1', 'ctx-y', 'ada', 'submitted', '0.3', 't', 't', '{}', '[]', '[]')`).run();
|
|
131
|
+
const pending = JSON.stringify({ contextId: "ctx-y", from: "ada", taskId: "t1", taskIds: ["t1"], kind: "inbound", preview: "hi" });
|
|
132
|
+
db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('ctx-y', 5, ?, 5)").run(pending);
|
|
133
|
+
assert.deepEqual(applyMigrations(db), ["0010_conversation_peer.sql"]);
|
|
134
|
+
const row = db.prepare("SELECT peer, pending_json, last_sent_ms FROM wakes WHERE context_id = 'ctx-y'").get() as { peer: string; pending_json: string; last_sent_ms: number };
|
|
135
|
+
assert.equal(row.peer, "ada");
|
|
136
|
+
assert.equal(row.last_sent_ms, 5);
|
|
137
|
+
assert.equal(JSON.parse(row.pending_json).preview, "hi");
|
|
138
|
+
db.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms) VALUES ('ctx-y', 'bob', 6)").run();
|
|
139
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-y'").get() as { n: number }).n, 2);
|
|
140
|
+
assert.equal((db.prepare("SELECT pending_json FROM wakes WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as { pending_json: string }).pending_json, pending);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("0011 keeps one owner per context from the earliest task, then history, then outbound", () => {
|
|
144
|
+
const prior = [...FILES].sort(byNumber).filter((f) => f !== "0011_conversation_owner.sql");
|
|
145
|
+
const db = existingDb(prior.map(sql), prior);
|
|
146
|
+
const task = `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
|
|
147
|
+
VALUES (?, ?, ?, 'submitted', '0.3', ?, ?, '{}', '[]', '[]')`;
|
|
148
|
+
db.prepare(task).run("t-bob", "ctx-y", "bob", "2026-02-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z");
|
|
149
|
+
db.prepare(task).run("t-ada", "ctx-y", "ada", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
|
|
150
|
+
db.prepare("INSERT INTO history (context_id, ts, dir, peer, text) VALUES ('ctx-h', '2026-01-01T00:00:00.000Z', 'local', 'ada', 'note')").run();
|
|
151
|
+
db.prepare("INSERT INTO outbound (task_id, context_id, peer, sent_at) VALUES ('o1', 'ctx-o', 'alias', '2026-01-01T00:00:00.000Z')").run();
|
|
152
|
+
assert.deepEqual(applyMigrations(db), ["0011_conversation_owner.sql"]);
|
|
153
|
+
const owner = (id: string) => (db.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").get(id) as { peer: string }).peer;
|
|
154
|
+
assert.equal(owner("ctx-y"), "ada");
|
|
155
|
+
assert.equal((db.prepare("SELECT COUNT(*) AS n FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { n: number }).n, 1);
|
|
156
|
+
assert.equal(owner("ctx-h"), "ada");
|
|
157
|
+
assert.equal(owner("ctx-o"), "alias");
|
|
158
|
+
assert.throws(() => db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'bob', 'later')").run());
|
|
159
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// A2A-Version selects the response shape; the extended card is -32007. Run: npm test
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import worker from "../src/index.ts";
|
|
5
|
+
import { d1 } from "./d1.ts";
|
|
6
|
+
|
|
7
|
+
const BASE = "https://agent.example.com";
|
|
8
|
+
|
|
9
|
+
function setup() {
|
|
10
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
11
|
+
const env: any = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", PAIRING_APPROVAL: "off" };
|
|
12
|
+
const call = async (method: string, path: string, o: { json?: any; headers?: Record<string, string> } = {}) => {
|
|
13
|
+
const headers: Record<string, string> = { "cf-connecting-ip": "198.51.100.7", ...(o.headers || {}) };
|
|
14
|
+
let body: string | undefined;
|
|
15
|
+
if (o.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(o.json); }
|
|
16
|
+
const pending: Promise<unknown>[] = [];
|
|
17
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers, body }) as any, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
18
|
+
await Promise.allSettled(pending);
|
|
19
|
+
const text = await res.text();
|
|
20
|
+
let data: any = text;
|
|
21
|
+
try { data = JSON.parse(text); } catch { /* text */ }
|
|
22
|
+
return { status: res.status, data, text };
|
|
23
|
+
};
|
|
24
|
+
return { call };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("A2A-Version selects the response shape; an absent header keeps the method name", async () => {
|
|
28
|
+
const s = setup();
|
|
29
|
+
const issued = await s.call("POST", "/owner/peers", { json: { label: "ada" }, headers: { authorization: "Bearer owner-secret" } });
|
|
30
|
+
assert.equal(issued.status, 200, issued.text);
|
|
31
|
+
const token = issued.data.token as string;
|
|
32
|
+
let n = 0;
|
|
33
|
+
const msg = () => ({ message: { role: "user", messageId: `m${++n}`, parts: [{ kind: "text", text: "hi" }] } });
|
|
34
|
+
const rpc = (method: string, params: any, headers: Record<string, string> = {}, path = "/") =>
|
|
35
|
+
s.call("POST", path, { json: { jsonrpc: "2.0", id: 1, method, params }, headers: { authorization: `Bearer ${token}`, ...headers } });
|
|
36
|
+
|
|
37
|
+
const v1 = await rpc("message/send", msg(), { "a2a-version": "1.0" });
|
|
38
|
+
assert.equal(v1.status, 200, v1.text);
|
|
39
|
+
assert.equal(v1.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
40
|
+
assert.equal(v1.data.result.task.kind, undefined);
|
|
41
|
+
assert.equal(v1.data.result.task.history[0].role, "ROLE_USER");
|
|
42
|
+
assert.equal(v1.data.result.task.history[0].parts[0].kind, undefined);
|
|
43
|
+
assert.equal(v1.data.result.task.history[0].parts[0].text, "hi");
|
|
44
|
+
|
|
45
|
+
const v03 = await rpc("SendMessage", msg(), { "a2a-version": "0.3" });
|
|
46
|
+
assert.equal(v03.data.result.kind, "task");
|
|
47
|
+
assert.equal(v03.data.result.status.state, "submitted");
|
|
48
|
+
assert.equal(v03.data.result.task, undefined);
|
|
49
|
+
assert.equal(v03.data.result.history[0].role, "user");
|
|
50
|
+
assert.equal(v03.data.result.history[0].kind, "message");
|
|
51
|
+
assert.equal(v03.data.result.history[0].parts[0].kind, "text");
|
|
52
|
+
|
|
53
|
+
const bare03 = await rpc("message/send", msg());
|
|
54
|
+
assert.equal(bare03.data.result.kind, "task");
|
|
55
|
+
assert.equal(bare03.data.result.status.state, "submitted");
|
|
56
|
+
const bare1 = await rpc("SendMessage", msg());
|
|
57
|
+
assert.equal(bare1.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
58
|
+
|
|
59
|
+
const patch1 = await rpc("message/send", msg(), { "a2a-version": "1.0.1" });
|
|
60
|
+
assert.equal(patch1.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
61
|
+
const patch03 = await rpc("SendMessage", msg(), { "A2A-Version": "0.3.0" });
|
|
62
|
+
assert.equal(patch03.data.result.kind, "task");
|
|
63
|
+
const short1 = await rpc("message/send", msg(), { "a2a-version": "1" });
|
|
64
|
+
assert.equal(short1.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
65
|
+
|
|
66
|
+
const query = await rpc("SendMessage", msg(), {}, "/?A2A-Version=0.3");
|
|
67
|
+
assert.equal(query.data.result.kind, "task");
|
|
68
|
+
assert.equal(query.data.result.status.state, "submitted");
|
|
69
|
+
const headerWins = await rpc("SendMessage", msg(), { "a2a-version": "1.0" }, "/?A2A-Version=0.3");
|
|
70
|
+
assert.equal(headerWins.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
71
|
+
|
|
72
|
+
const created = bare1.data.result.task.id as string;
|
|
73
|
+
const reread = await rpc("GetTask", { id: created }, { "a2a-version": "0.3" });
|
|
74
|
+
assert.equal(reread.data.result.kind, "task");
|
|
75
|
+
assert.equal(reread.data.result.status.state, "submitted");
|
|
76
|
+
assert.equal(reread.data.result.id, created);
|
|
77
|
+
|
|
78
|
+
for (const method of ["SendMessage", "no-such-method"]) {
|
|
79
|
+
const bad = await rpc(method, msg(), { "a2a-version": "2.0" });
|
|
80
|
+
assert.equal(bad.data.error?.code, -32009, method);
|
|
81
|
+
assert.match(bad.data.error.message, /2\.0/);
|
|
82
|
+
}
|
|
83
|
+
const minor = await rpc("SendMessage", msg(), { "a2a-version": "1.2" });
|
|
84
|
+
assert.equal(minor.data.error?.code, -32009);
|
|
85
|
+
const unknown = await rpc("no-such-method", {});
|
|
86
|
+
assert.equal(unknown.data.error?.code, -32601);
|
|
87
|
+
const unknownOk = await rpc("no-such-method", {}, { "a2a-version": "1.0" });
|
|
88
|
+
assert.equal(unknownOk.data.error?.code, -32601);
|
|
89
|
+
|
|
90
|
+
for (const method of ["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]) {
|
|
91
|
+
const missing = await rpc(method, {});
|
|
92
|
+
assert.equal(missing.status, 200);
|
|
93
|
+
assert.equal(missing.data.error?.code, -32007, method);
|
|
94
|
+
assert.equal(missing.data.error?.message, "Extended agent card is not configured");
|
|
95
|
+
const withHeader = await rpc(method, {}, { "a2a-version": "0.3" });
|
|
96
|
+
assert.equal(withHeader.data.error?.code, -32007, method);
|
|
97
|
+
}
|
|
98
|
+
const extFirst = await rpc("GetExtendedAgentCard", {}, { "a2a-version": "2.0" });
|
|
99
|
+
assert.equal(extFirst.data.error?.code, -32009);
|
|
100
|
+
});
|