a2a-exposed 0.8.16 → 0.8.18
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/commands.mjs +4 -1
- package/lib/main.mjs +9 -6
- 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/migrations/0012_conversation_peer_owner.sql +13 -0
- package/worker/src/export.ts +1 -1
- package/worker/src/index.ts +272 -56
- package/worker/src/mcp.ts +2 -1
- package/worker/src/migrations.ts +6 -0
- package/worker/src/owner-rpc.ts +11 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +4 -2
- package/worker/test/context-peer.test.ts +290 -0
- package/worker/test/facade.test.ts +6 -2
- package/worker/test/list-tasks.test.ts +120 -0
- package/worker/test/migrations.test.ts +52 -5
- package/worker/test/owner-rpc.test.ts +4 -0
- package/worker/test/push-config.test.ts +161 -0
- package/worker/test/wake.test.ts +2 -2
package/worker/src/index.ts
CHANGED
|
@@ -67,6 +67,44 @@ 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 this peer's conversation. The primary key is (context_id, peer): another peer's row is a
|
|
71
|
+
* different conversation and is not read or replaced. The insert does nothing when this peer already holds the 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) VALUES (?1, ?2, ?3)
|
|
75
|
+
ON CONFLICT(context_id, peer) DO NOTHING`,
|
|
76
|
+
).bind(contextId, peer, ts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A new task is stored only when this peer owns the context. Runs in the same batch as `claimContextStmt`. */
|
|
80
|
+
function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
|
|
81
|
+
return ctx.DB.prepare(
|
|
82
|
+
`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
|
|
83
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
|
|
84
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
|
|
85
|
+
ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
|
|
86
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
|
|
87
|
+
).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
|
|
88
|
+
JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
|
|
92
|
+
return ctx.DB.prepare(
|
|
93
|
+
`INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
|
|
94
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
|
95
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
|
|
96
|
+
).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner: string) {
|
|
100
|
+
return ctx.DB.prepare(
|
|
101
|
+
`INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
|
|
102
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
|
|
103
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
|
|
104
|
+
).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
|
|
105
|
+
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
// ------------------------------------------------------------------ self-aware fetch (a Worker cannot reliably fetch its own custom domain)
|
|
71
109
|
/**
|
|
72
110
|
* Another tenant on this deployment's domain. A public fetch of that hostname returns 522, so the call
|
|
@@ -118,7 +156,7 @@ async function doFetch(ctx: TenantContext, ectx: ExecutionContext, url: string,
|
|
|
118
156
|
return fetch(url, { ...init, signal: AbortSignal.timeout(15000) });
|
|
119
157
|
}
|
|
120
158
|
|
|
121
|
-
// ------------------------------------------------------------------ wake webhook (presets), debounced per context via D1
|
|
159
|
+
// ------------------------------------------------------------------ wake webhook (presets), debounced per peer and context via D1
|
|
122
160
|
const preset = (ctx: TenantContext) => (ctx.WAKE_PRESET || "generic").trim();
|
|
123
161
|
|
|
124
162
|
function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
@@ -207,6 +245,21 @@ const WAKE_RETRIES = 3;
|
|
|
207
245
|
* 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
246
|
* WAKE_RETRIES times per wake; a newer message for the conversation merges in and starts over. Pairing and test wakes
|
|
209
247
|
* are not retried (a pairing code lasts 60 minutes). */
|
|
248
|
+
function pendingWakePeer(pending: string | null | undefined): string {
|
|
249
|
+
if (!pending) return "";
|
|
250
|
+
try {
|
|
251
|
+
const from = JSON.parse(pending)?.from;
|
|
252
|
+
return typeof from === "string" ? from : "";
|
|
253
|
+
} catch { return ""; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The wake row for this peer's conversation. A row stored before peer was part of the key has peer ''. */
|
|
257
|
+
async function loadWake(ctx: TenantContext, contextId: string, peer: string): Promise<Json | null> {
|
|
258
|
+
const exact: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
|
|
259
|
+
if (exact || !peer) return exact;
|
|
260
|
+
return ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ''").bind(contextId).first();
|
|
261
|
+
}
|
|
262
|
+
|
|
210
263
|
async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
|
|
211
264
|
if (payload.kind !== "inbound") return;
|
|
212
265
|
const attempts = (Number(payload.attempts) || 0) + 1;
|
|
@@ -214,16 +267,18 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
|
|
|
214
267
|
const secs = Number(retryAfter);
|
|
215
268
|
const waitMs = Math.min(3600, Math.max(30, Number.isFinite(secs) && secs > 0 ? secs : 60)) * 1000;
|
|
216
269
|
const now = Date.now(), contextId = payload.contextId;
|
|
217
|
-
const
|
|
270
|
+
const peer = typeof payload.from === "string" ? payload.from : "";
|
|
271
|
+
const cur: Json = await loadWake(ctx, contextId, peer);
|
|
218
272
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
219
273
|
const merged = { ...payload, attempts, taskIds: [...new Set([...(prev?.taskIds || []), ...(payload.taskIds || [])])] };
|
|
220
274
|
// flushWake and nextCronAt wait until last_sent_ms + debounce. Store Retry-After minus that debounce so the
|
|
221
275
|
// deadline is Retry-After itself, including when the debounce is longer than Retry-After. Do not keep the
|
|
222
276
|
// previous last_sent_ms: that clock is the send we just made, and keeping it would wait out the debounce.
|
|
223
277
|
const due = now + waitMs - debounceMs(ctx);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
const rowPeer = cur && (cur.peer === peer || cur.peer === "" || cur.peer == null) ? (cur.peer ?? "") : peer;
|
|
279
|
+
await ctx.DB.prepare(`INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?, ?)
|
|
280
|
+
ON CONFLICT(context_id, peer) DO UPDATE SET pending_json = excluded.pending_json, last_sent_ms = excluded.last_sent_ms,
|
|
281
|
+
pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms)`).bind(contextId, rowPeer === "" ? peer : rowPeer, due, JSON.stringify(merged), now).run();
|
|
227
282
|
log("wake_requeued", { contextId, retryInS: Math.round(waitMs / 1000), attempt: attempts });
|
|
228
283
|
// The row exists now. A hosted alarm has to be set from this turn: the request that started sendWake may already have returned.
|
|
229
284
|
await noteWakeWrite(ectx);
|
|
@@ -232,33 +287,45 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
|
|
|
232
287
|
async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, taskId: string, from: string, preview: string, kind = "inbound") {
|
|
233
288
|
const payload: Json = { contextId, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind };
|
|
234
289
|
const now = Date.now(), deb = debounceMs(ctx);
|
|
235
|
-
|
|
290
|
+
let row: Json = await loadWake(ctx, contextId, from);
|
|
291
|
+
// A pre-peer row (peer '') is this conversation when it names this peer, or names nobody yet.
|
|
292
|
+
// Another peer's legacy row stays put; this peer gets its own row.
|
|
293
|
+
if (row && row.peer === "" && from) {
|
|
294
|
+
const named = pendingWakePeer(row.pending_json);
|
|
295
|
+
if (!named || named === from) {
|
|
296
|
+
const moved = await ctx.DB.prepare("UPDATE wakes SET peer = ? WHERE context_id = ? AND peer = ''").bind(from, contextId).run();
|
|
297
|
+
if (changed(moved) === 1) row = { ...row, peer: from };
|
|
298
|
+
else row = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, from).first();
|
|
299
|
+
} else row = null;
|
|
300
|
+
}
|
|
301
|
+
const peer = row?.peer ?? from;
|
|
236
302
|
let claimed = false;
|
|
237
303
|
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();
|
|
304
|
+
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
305
|
claimed = r.meta.changes === 1;
|
|
240
306
|
} 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();
|
|
307
|
+
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")
|
|
308
|
+
.bind(now, contextId, peer, row.last_sent_ms).run();
|
|
243
309
|
claimed = r.meta.changes === 1;
|
|
244
310
|
}
|
|
245
311
|
if (claimed && (await wakeBudgetOk(ctx))) { ectx.waitUntil(sendWake(ctx, ectx, payload)); return; }
|
|
246
312
|
// debounce window (or hourly cap reached): merge into the pending wake and flush it later
|
|
247
|
-
const cur: Json = await ctx
|
|
313
|
+
const cur: Json = await loadWake(ctx, contextId, from);
|
|
314
|
+
const curPeer = cur?.peer ?? peer;
|
|
248
315
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
249
316
|
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();
|
|
317
|
+
await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ? AND peer = ?")
|
|
318
|
+
.bind(JSON.stringify(payload), now, contextId, curPeer).run();
|
|
252
319
|
// The pending row exists now. A hosted alarm is set in this turn, including when the delay is too long for waitUntil.
|
|
253
320
|
await noteWakeWrite(ectx);
|
|
254
321
|
const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
|
|
255
322
|
// waitUntil can outlive the response by ~30s. Longer windows are flushed by a later request, the optional
|
|
256
323
|
// 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)));
|
|
324
|
+
if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId, curPeer)));
|
|
258
325
|
}
|
|
259
326
|
|
|
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();
|
|
327
|
+
async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, peer: string) {
|
|
328
|
+
const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
|
|
262
329
|
if (!row || !row.pending_json) return;
|
|
263
330
|
const now = Date.now();
|
|
264
331
|
if (now - row.last_sent_ms < debounceMs(ctx)) return;
|
|
@@ -268,16 +335,16 @@ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId:
|
|
|
268
335
|
if ((b?.count ?? 0) >= maxPerHour(ctx)) return; // keep pending until the next hour
|
|
269
336
|
}
|
|
270
337
|
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();
|
|
338
|
+
"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",
|
|
339
|
+
).bind(now, contextId, peer, row.last_sent_ms).run();
|
|
273
340
|
if (r.meta.changes === 1 && (await wakeBudgetOk(ctx))) await sendWake(ctx, ectx, JSON.parse(row.pending_json));
|
|
274
341
|
}
|
|
275
342
|
|
|
276
343
|
/** Flush every pending wake whose debounce window has passed (called opportunistically and from cron). */
|
|
277
344
|
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")
|
|
345
|
+
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
346
|
.bind(Date.now() - debounceMs(ctx)).all();
|
|
280
|
-
for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id);
|
|
347
|
+
for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id, typeof r.peer === "string" ? r.peer : "");
|
|
281
348
|
}
|
|
282
349
|
|
|
283
350
|
/**
|
|
@@ -563,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
|
|
|
563
630
|
const V1: Record<string, string> = {
|
|
564
631
|
SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
|
|
565
632
|
SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
|
|
633
|
+
ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
|
|
634
|
+
DeleteTaskPushNotificationConfig: "pushdel",
|
|
566
635
|
SendStreamingMessage: "stream", SubscribeToTask: "stream",
|
|
567
636
|
};
|
|
568
637
|
const V03: Record<string, string> = {
|
|
569
638
|
"message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
|
|
570
|
-
"tasks/pushNotificationConfig/get": "pushget", "
|
|
639
|
+
"tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
|
|
640
|
+
"tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
|
|
571
641
|
};
|
|
572
|
-
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
573
|
-
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
574
|
-
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
575
642
|
const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
|
|
576
643
|
|
|
577
644
|
/** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
|
|
@@ -587,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
|
|
|
587
654
|
return { raw, version: null };
|
|
588
655
|
}
|
|
589
656
|
|
|
590
|
-
function rpcOperation(method: string
|
|
657
|
+
function rpcOperation(method: string): { op: string; version: string } | null {
|
|
591
658
|
if (V1[method]) return { op: V1[method], version: "1.0" };
|
|
592
659
|
if (V03[method]) return { op: V03[method], version: "0.3" };
|
|
593
|
-
if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
|
|
594
|
-
if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
|
|
595
660
|
return null;
|
|
596
661
|
}
|
|
597
662
|
|
|
@@ -602,6 +667,10 @@ async function ownedTask(ctx: TenantContext, id: unknown, label: string): Promis
|
|
|
602
667
|
return t;
|
|
603
668
|
}
|
|
604
669
|
|
|
670
|
+
/** A contextId this caller does not get to use. The same sentence is returned for an id the façade has no
|
|
671
|
+
* record of. It does not say whether the id exists, or which peer holds it. */
|
|
672
|
+
const UNKNOWN_CONTEXT = "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)";
|
|
673
|
+
|
|
605
674
|
function normPushCfg(cfg: Json, version: string): Json {
|
|
606
675
|
if (!cfg || typeof cfg !== "object" || typeof cfg.url !== "string") throw new RpcError(-32602, "push notification config needs url");
|
|
607
676
|
const out: Json = { id: typeof cfg.id === "string" && cfg.id ? cfg.id.slice(0, 128) : A.newId(), url: cfg.url, _version: version };
|
|
@@ -634,6 +703,12 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
634
703
|
}
|
|
635
704
|
const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
|
|
636
705
|
const ts = A.nowIso();
|
|
706
|
+
// A2A lets an agent accept a client-supplied contextId and forbids minting a replacement when it will not.
|
|
707
|
+
// This inbox accepts the id for the caller. A peer that already has it continues that conversation. An id
|
|
708
|
+
// this agent has never seen, and an id another peer already has, are the same for the caller: a new
|
|
709
|
+
// conversation of their own. The other peer's tasks, history, and wake are not read or written. The claim
|
|
710
|
+
// and the first task are one batch, so the task is stored only when this peer's owner row is present.
|
|
711
|
+
const creating = !task;
|
|
637
712
|
if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
|
|
638
713
|
msg.contextId = contextId; msg.taskId = task.id;
|
|
639
714
|
task.history.push(msg);
|
|
@@ -641,10 +716,21 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
641
716
|
if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
|
|
642
717
|
if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
|
|
643
718
|
const preview = A.textOf(msg);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
719
|
+
if (creating) {
|
|
720
|
+
const stored = await ctx.DB.batch([
|
|
721
|
+
claimContextStmt(ctx, contextId, label, ts),
|
|
722
|
+
saveTaskIfOwnerStmt(ctx, task),
|
|
723
|
+
msgIfOwnerStmt(ctx, task, msg),
|
|
724
|
+
histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
|
|
725
|
+
]);
|
|
726
|
+
const wrote = stored[1]?.meta?.changes ?? 0;
|
|
727
|
+
if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
|
|
728
|
+
} else {
|
|
729
|
+
await ctx.DB.batch([
|
|
730
|
+
saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
|
|
731
|
+
histStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }),
|
|
732
|
+
]);
|
|
733
|
+
}
|
|
648
734
|
log("message_received", { peer: label, contextId, taskId: task.id, chars: preview.length, version });
|
|
649
735
|
await wake(ctx, ectx, contextId, task.id, label, preview);
|
|
650
736
|
const pt = A.publicTask(task, version, conf.historyLength);
|
|
@@ -680,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
|
|
|
680
766
|
throw new RpcError(-32001, "Push notification config not found");
|
|
681
767
|
};
|
|
682
768
|
|
|
769
|
+
/** Config id for list/delete. 1.0 sends `{ taskId, id }`. 0.3 sends `{ id: taskId, pushNotificationConfigId }`. */
|
|
770
|
+
function pushConfigId(p: Json): string | undefined {
|
|
771
|
+
const id = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
|
|
772
|
+
return typeof id === "string" && id ? id : undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Cursor over this task's configs. The token is an index, not a config id, url, or token. */
|
|
776
|
+
function pushListStart(raw: unknown): number {
|
|
777
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
778
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
779
|
+
let text = "";
|
|
780
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
781
|
+
const m = /^cfg\n(\d{1,6})$/.exec(text);
|
|
782
|
+
if (!m) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
783
|
+
return Number(m[1]);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const rpcPushList: Rpc = async (p, label, version, ctx) => {
|
|
787
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
788
|
+
const all = (t._push || []).map((c: Json) => pubCfg(t.id, c, version));
|
|
789
|
+
if (!version.startsWith("1")) return all;
|
|
790
|
+
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
791
|
+
const start = pushListStart(p.pageToken);
|
|
792
|
+
const configs = all.slice(start, start + size);
|
|
793
|
+
const next = start + configs.length;
|
|
794
|
+
return { configs, nextPageToken: next < all.length ? b64url(`cfg\n${next}`) : "" };
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const rpcPushDel: Rpc = async (p, label, version, ctx) => {
|
|
798
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
799
|
+
const id = pushConfigId(p);
|
|
800
|
+
if (!id) throw new RpcError(-32602, "Invalid params: push notification config id");
|
|
801
|
+
const kept = (t._push || []).filter((c: Json) => c.id !== id);
|
|
802
|
+
if (kept.length !== (t._push || []).length) {
|
|
803
|
+
t._push = kept;
|
|
804
|
+
await saveTaskStmt(ctx, t).run();
|
|
805
|
+
}
|
|
806
|
+
// Idempotent: a missing config on this peer's task is the same as a config already removed.
|
|
807
|
+
return version.startsWith("1") ? {} : null;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/** Keyset cursor for ListTasks, newest update first. Empty means the first page. A bad token is rejected
|
|
811
|
+
* without echoing it. The query still filters by the caller, so the cursor cannot select another peer's tasks. */
|
|
812
|
+
function listCursor(raw: unknown): { updatedAt: string; id: string } | null {
|
|
813
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
814
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
815
|
+
let text = "";
|
|
816
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
817
|
+
const nl = text.indexOf("\n");
|
|
818
|
+
if (nl <= 0) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
819
|
+
const updatedAt = text.slice(0, nl);
|
|
820
|
+
const id = text.slice(nl + 1);
|
|
821
|
+
if (!INBOX_STAMP.test(updatedAt) || !A.ID_RE.test(id) || id.includes("\n")) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
822
|
+
return { updatedAt, id };
|
|
823
|
+
}
|
|
824
|
+
|
|
683
825
|
const rpcList: Rpc = async (p, label, version, ctx) => {
|
|
684
826
|
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
const
|
|
688
|
-
|
|
827
|
+
const cursor = listCursor(p.pageToken);
|
|
828
|
+
const where = ["peer = ?"];
|
|
829
|
+
const args: Json[] = [label];
|
|
830
|
+
if (p.contextId !== undefined && p.contextId !== "") {
|
|
831
|
+
if (typeof p.contextId !== "string" || !A.ID_RE.test(p.contextId)) throw new RpcError(-32602, "Invalid params: invalid contextId");
|
|
832
|
+
where.push("context_id = ?");
|
|
833
|
+
args.push(p.contextId);
|
|
834
|
+
}
|
|
835
|
+
const whereSql = where.join(" AND ");
|
|
836
|
+
const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${whereSql}`).bind(...args).first();
|
|
837
|
+
const pageWhere = [...where];
|
|
838
|
+
const pageArgs = [...args];
|
|
839
|
+
if (cursor) {
|
|
840
|
+
pageWhere.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
|
|
841
|
+
pageArgs.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
|
|
842
|
+
}
|
|
843
|
+
const rows = await ctx.DB.prepare(
|
|
844
|
+
`SELECT id, updated_at FROM tasks WHERE ${pageWhere.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
|
845
|
+
).bind(...pageArgs, size + 1).all();
|
|
846
|
+
const found = (rows.results || []) as Json[];
|
|
847
|
+
const page = found.slice(0, size);
|
|
689
848
|
const tasks = [];
|
|
690
|
-
for (const r of
|
|
849
|
+
for (const r of page) {
|
|
691
850
|
const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
|
|
692
851
|
if (!p.includeArtifacts) delete t.artifacts;
|
|
693
852
|
tasks.push(t);
|
|
694
853
|
}
|
|
695
|
-
|
|
854
|
+
let nextPageToken = "";
|
|
855
|
+
if (found.length > size && page.length) {
|
|
856
|
+
const updatedAt = String(page[page.length - 1].updated_at);
|
|
857
|
+
const id = String(page[page.length - 1].id);
|
|
858
|
+
if (INBOX_STAMP.test(updatedAt) && A.ID_RE.test(id)) nextPageToken = b64url(`${updatedAt}\n${id}`);
|
|
859
|
+
}
|
|
860
|
+
return { tasks, nextPageToken, pageSize: size, totalSize: total?.n ?? tasks.length };
|
|
696
861
|
};
|
|
697
862
|
|
|
698
|
-
const HANDLERS: Record<string, Rpc> = {
|
|
863
|
+
const HANDLERS: Record<string, Rpc> = {
|
|
864
|
+
send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
|
|
865
|
+
pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
|
|
866
|
+
};
|
|
699
867
|
|
|
700
868
|
/** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
|
|
701
869
|
* The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
|
|
@@ -742,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
|
|
|
742
910
|
const declared = declaredA2AVersion(req);
|
|
743
911
|
if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
|
|
744
912
|
if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
|
|
745
|
-
const found = rpcOperation(method
|
|
913
|
+
const found = rpcOperation(method);
|
|
746
914
|
if (!found) return err(-32601, "Method not found");
|
|
747
915
|
const version = declared.version || found.version;
|
|
748
916
|
const op = found.op;
|
|
@@ -857,7 +1025,7 @@ async function proxyRpc(req: Request, ctx: TenantContext, c: { raw: string; rid:
|
|
|
857
1025
|
// may still be a live upstream context (another peer's, or one opened outside the façade), and the façade can't
|
|
858
1026
|
// tell, so client-chosen context ids are refused; omit contextId to start a conversation and reuse the returned one.
|
|
859
1027
|
if ((await ownerOf(ctx, "context", m.contextId)) !== c.label)
|
|
860
|
-
return err(-32602,
|
|
1028
|
+
return err(-32602, UNKNOWN_CONTEXT);
|
|
861
1029
|
}
|
|
862
1030
|
} else {
|
|
863
1031
|
const tid = F.taskIdOf(c.op, c.params);
|
|
@@ -994,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
|
|
|
994
1162
|
|
|
995
1163
|
// ------------------------------------------------------------------ owner API (local CLI)
|
|
996
1164
|
const INBOX_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
|
|
1165
|
+
/** Inbound labels and outbound aliases. Used to scope a conversation read to one peer. */
|
|
1166
|
+
const PEER_FILTER = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
997
1167
|
|
|
998
1168
|
function inboxLimit(raw: string | null): number {
|
|
999
1169
|
if (raw === null) return M.INBOX_MAX;
|
|
@@ -1120,22 +1290,25 @@ async function ackPendingWakes(ctx: TenantContext, seen: { taskId: string; conte
|
|
|
1120
1290
|
byContext.set(row.contextId, ids);
|
|
1121
1291
|
}
|
|
1122
1292
|
for (const [contextId, seenIds] of byContext) {
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1293
|
+
const rows = await ctx.DB.prepare("SELECT peer, pending_json FROM wakes WHERE context_id = ? AND pending_json IS NOT NULL").bind(contextId).all();
|
|
1294
|
+
for (const row of (rows.results || []) as Json[]) {
|
|
1295
|
+
if (typeof row?.pending_json !== "string") continue;
|
|
1296
|
+
let payload: Json;
|
|
1297
|
+
try { payload = JSON.parse(row.pending_json); } catch { continue; }
|
|
1298
|
+
const wakeKind = typeof payload?.kind === "string" && payload.kind ? payload.kind : "inbound";
|
|
1299
|
+
if (wakeKind !== kind) continue;
|
|
1300
|
+
if (typeof payload?.contextId === "string" && payload.contextId !== contextId) continue;
|
|
1301
|
+
const ids = wakeTaskIds(payload);
|
|
1302
|
+
if (!ids.some((id) => seenIds.has(id))) continue;
|
|
1303
|
+
const kept = ids.filter((id) => !seenIds.has(id));
|
|
1304
|
+
const peer = typeof row.peer === "string" ? row.peer : "";
|
|
1305
|
+
if (!kept.length) {
|
|
1306
|
+
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();
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
|
|
1310
|
+
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();
|
|
1136
1311
|
}
|
|
1137
|
-
const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
|
|
1138
|
-
await ctx.DB.prepare("UPDATE wakes SET pending_json = ? WHERE context_id = ? AND pending_json = ?").bind(JSON.stringify(next), contextId, row.pending_json).run();
|
|
1139
1312
|
}
|
|
1140
1313
|
}
|
|
1141
1314
|
|
|
@@ -1177,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1177
1350
|
}
|
|
1178
1351
|
if (seg[0] === "inbox" && m === "GET") {
|
|
1179
1352
|
const contextFilter = url.searchParams.get("context");
|
|
1353
|
+
const fromFilter = url.searchParams.get("from");
|
|
1354
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) throw new HttpError(400, "from is invalid");
|
|
1180
1355
|
const all = url.searchParams.get("all") === "1";
|
|
1181
1356
|
const limit = inboxLimit(url.searchParams.get("limit"));
|
|
1182
1357
|
const cursor = inboxCursor(url.searchParams.get("cursor"));
|
|
@@ -1184,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1184
1359
|
const args: Json[] = [];
|
|
1185
1360
|
if (!all) q += " AND state IN ('submitted','working')";
|
|
1186
1361
|
if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
|
|
1362
|
+
if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
|
|
1187
1363
|
if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
|
|
1188
1364
|
q += " ORDER BY updated_at, id LIMIT ?";
|
|
1189
1365
|
args.push(limit + 1);
|
|
@@ -1205,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1205
1381
|
const oargs: Json[] = [];
|
|
1206
1382
|
if (!all) oq += " AND updates_seen = 0";
|
|
1207
1383
|
if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
|
|
1384
|
+
if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
|
|
1208
1385
|
oq += " ORDER BY sent_at LIMIT ?";
|
|
1209
1386
|
oargs.push(limit);
|
|
1210
1387
|
const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
|
|
@@ -1272,7 +1449,34 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1272
1449
|
}
|
|
1273
1450
|
if (seg[0] === "history" && seg[1] && m === "GET") {
|
|
1274
1451
|
const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
|
|
1275
|
-
const
|
|
1452
|
+
const fromFilter = url.searchParams.get("from");
|
|
1453
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
|
|
1454
|
+
// Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
|
|
1455
|
+
// With `from`, one peer's rows only, so another peer's conversation is not in this read.
|
|
1456
|
+
const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
|
|
1457
|
+
SELECT * FROM history WHERE context_id = ? AND (
|
|
1458
|
+
peer = ?
|
|
1459
|
+
OR (
|
|
1460
|
+
(peer IS NULL OR peer = '')
|
|
1461
|
+
AND (
|
|
1462
|
+
task_id IN (SELECT id FROM tasks WHERE context_id = ? AND peer = ?)
|
|
1463
|
+
OR task_id IN (SELECT task_id FROM outbound WHERE context_id = ? AND peer = ?)
|
|
1464
|
+
)
|
|
1465
|
+
)
|
|
1466
|
+
) ORDER BY seq DESC LIMIT ?
|
|
1467
|
+
) ORDER BY seq` : `SELECT * FROM (
|
|
1468
|
+
SELECT * FROM history WHERE context_id = ? AND (
|
|
1469
|
+
peer IS NULL OR peer = ''
|
|
1470
|
+
OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
1471
|
+
OR peer IN (SELECT peer FROM outbound WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
1472
|
+
OR (
|
|
1473
|
+
NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?)
|
|
1474
|
+
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
|
|
1475
|
+
)
|
|
1476
|
+
) ORDER BY seq DESC LIMIT ?
|
|
1477
|
+
) ORDER BY seq`).bind(...(fromFilter
|
|
1478
|
+
? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
|
|
1479
|
+
: [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
|
|
1276
1480
|
return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
|
|
1277
1481
|
}
|
|
1278
1482
|
if (seg[0] === "history" && !seg[1] && m === "POST") {
|
|
@@ -1434,6 +1638,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1434
1638
|
ctx.DB.prepare("DELETE FROM history WHERE context_id = ?").bind(c),
|
|
1435
1639
|
ctx.DB.prepare("DELETE FROM outbound WHERE context_id = ?").bind(c),
|
|
1436
1640
|
ctx.DB.prepare("DELETE FROM wakes WHERE context_id = ?").bind(c),
|
|
1641
|
+
ctx.DB.prepare("DELETE FROM conversation_owners WHERE context_id = ?").bind(c),
|
|
1437
1642
|
]);
|
|
1438
1643
|
n += res.reduce((s, r) => s + (r.meta.changes || 0), 0);
|
|
1439
1644
|
}
|
|
@@ -2713,6 +2918,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2713
2918
|
case "inbox": {
|
|
2714
2919
|
const qs = new URLSearchParams();
|
|
2715
2920
|
if (a.contextId !== undefined) qs.set("context", A.checkId(argStr(a, "contextId"), "contextId"));
|
|
2921
|
+
if (a.from !== undefined) {
|
|
2922
|
+
const from = argStr(a, "from");
|
|
2923
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2924
|
+
qs.set("from", from);
|
|
2925
|
+
}
|
|
2716
2926
|
if (a.all === true) qs.set("all", "1");
|
|
2717
2927
|
if (a.cursor !== undefined) {
|
|
2718
2928
|
const cursor = argStr(a, "cursor");
|
|
@@ -2740,7 +2950,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2740
2950
|
case "history": {
|
|
2741
2951
|
const n = a.n === undefined ? 50 : Number(a.n);
|
|
2742
2952
|
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
|
|
2743
|
-
|
|
2953
|
+
let histPath = `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`;
|
|
2954
|
+
if (a.from !== undefined) {
|
|
2955
|
+
const from = argStr(a, "from");
|
|
2956
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2957
|
+
histPath += `&from=${enc(from)}`;
|
|
2958
|
+
}
|
|
2959
|
+
return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", histPath)));
|
|
2744
2960
|
}
|
|
2745
2961
|
case "mark_working": {
|
|
2746
2962
|
const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/working`));
|
package/worker/src/mcp.ts
CHANGED
|
@@ -323,6 +323,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
323
323
|
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
324
324
|
inputSchema: { type: "object", properties: {
|
|
325
325
|
contextId: str("Only this conversation"),
|
|
326
|
+
from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
|
|
326
327
|
all: { type: "boolean", description: "Include finished tasks on this page" },
|
|
327
328
|
cursor: str("Cursor from the previous inbox page, when more tasks are waiting"),
|
|
328
329
|
limit: { type: "integer", minimum: 1, maximum: INBOX_MAX, description: `Tasks in this page (default ${INBOX_PAGE}, at most ${INBOX_MAX})` },
|
|
@@ -332,7 +333,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
332
333
|
inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
|
|
333
334
|
{ name: "history", title: "Conversation history", annotations: ro,
|
|
334
335
|
description: `The log of one conversation (both directions), oldest first. ${RULES}`,
|
|
335
|
-
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
|
+
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), from: str("Only this peer (the wake's from)"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
337
|
{ name: "mark_working", title: "Mark task working", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
337
338
|
description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
|
|
338
339
|
inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
|
package/worker/src/migrations.ts
CHANGED
|
@@ -9,6 +9,9 @@ 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";
|
|
14
|
+
import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
|
|
12
15
|
import type { Migration } from "./storage.ts";
|
|
13
16
|
|
|
14
17
|
export const MIGRATIONS: Migration[] = [
|
|
@@ -21,4 +24,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
21
24
|
{ name: "0007_cimd.sql", sql: m0007 },
|
|
22
25
|
{ name: "0008_oidc_txns.sql", sql: m0008 },
|
|
23
26
|
{ name: "0009_outbound_seen.sql", sql: m0009 },
|
|
27
|
+
{ name: "0010_conversation_peer.sql", sql: m0010 },
|
|
28
|
+
{ name: "0011_conversation_owner.sql", sql: m0011 },
|
|
29
|
+
{ name: "0012_conversation_peer_owner.sql", sql: m0012 },
|
|
24
30
|
];
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -105,25 +105,31 @@ function noQuery(q: Record<string, string>): string | null {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function inboxQuery(q: Record<string, string>): string | null {
|
|
108
|
-
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor") return null;
|
|
108
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor" && k !== "from") return null;
|
|
109
109
|
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
110
110
|
if (q.all !== undefined && q.all !== "1") return null;
|
|
111
111
|
if (q.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(q.limit) || Number(q.limit) > 200)) return null;
|
|
112
112
|
if (q.cursor !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(q.cursor)) return null;
|
|
113
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
113
114
|
const sp = new URLSearchParams();
|
|
114
115
|
if (q.context) sp.set("context", q.context);
|
|
115
116
|
if (q.all) sp.set("all", "1");
|
|
116
117
|
if (q.limit) sp.set("limit", q.limit);
|
|
117
118
|
if (q.cursor) sp.set("cursor", q.cursor);
|
|
119
|
+
if (q.from) sp.set("from", q.from);
|
|
118
120
|
const s = sp.toString();
|
|
119
121
|
return s ? `?${s}` : "";
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
function historyQuery(q: Record<string, string>): string | null {
|
|
123
|
-
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
124
|
-
if (q.n
|
|
125
|
-
if (!/^[
|
|
126
|
-
|
|
125
|
+
for (const k of Object.keys(q)) if (k !== "n" && k !== "from") return null;
|
|
126
|
+
if (q.n !== undefined && (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000)) return null;
|
|
127
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
128
|
+
const sp = new URLSearchParams();
|
|
129
|
+
if (q.n) sp.set("n", q.n);
|
|
130
|
+
if (q.from) sp.set("from", q.from);
|
|
131
|
+
const s = sp.toString();
|
|
132
|
+
return s ? `?${s}` : "";
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
|
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.18";
|
package/worker/src/wake.ts
CHANGED
|
@@ -92,9 +92,11 @@ export function pairingInstructions(p: PairingInfo, cli: string): string {
|
|
|
92
92
|
|
|
93
93
|
function hintFor(ev: WakeEvent, cli: string): string {
|
|
94
94
|
if (ev.kind === "pairing_request") return `${cli} pair list`;
|
|
95
|
+
// `--from` keeps the read on this peer. The same context id can be another peer's separate conversation.
|
|
96
|
+
const from = /^[A-Za-z0-9_.-]{1,64}$/.test(ev.from) ? ` --from ${ev.from}` : "";
|
|
95
97
|
return ev.kind === "outbound_update"
|
|
96
|
-
? `${cli} history ${ev.contextId}`
|
|
97
|
-
: `${cli} inbox --context ${ev.contextId}`;
|
|
98
|
+
? `${cli} history ${ev.contextId}${from}`
|
|
99
|
+
: `${cli} inbox --context ${ev.contextId}${from}`;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** Human/agent-readable wake text. `withPreview=false` keeps peer-authored text out entirely. */
|