dsh-a2a-groups 0.0.0-stage → 0.1.2
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/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/lib/core/wire.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A 1.0 wire format: encoding, decoding, SSE framing, and the task store.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only place that knows the on-the-wire spelling of an A2A
|
|
5
|
+
* message. Everything above it (server, deliverer, skills) speaks plain JS
|
|
6
|
+
* values; everything a caller sees comes out of here. Keeping the vocabulary in
|
|
7
|
+
* one file is what makes "we speak 1.0, not 0.3" a single, testable decision
|
|
8
|
+
* instead of a convention scattered across handlers.
|
|
9
|
+
*
|
|
10
|
+
* Boundaries:
|
|
11
|
+
* - No DSH concept may appear here. No session, no plugin ctx, no delivery.
|
|
12
|
+
* A reviewer should be able to hand this file to any A2A peer author.
|
|
13
|
+
* - Time is epoch milliseconds inside (`TaskStore`), ISO-8601 only at the edges
|
|
14
|
+
* where the wire demands it. Conversions live in `toWireTask()` /
|
|
15
|
+
* `statusUpdate()` so no caller has to remember the rule.
|
|
16
|
+
* - `TaskStore` persistence is best-effort by contract: a failed disk write must
|
|
17
|
+
* never fail an in-flight A2A call, because the task already exists in memory
|
|
18
|
+
* and the remote peer has no way to undo its retry.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-a2a/wire
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One task row as `TaskStore` keeps it.
|
|
29
|
+
*
|
|
30
|
+
* Declared as a typedef rather than left as `{object}` because the row is the
|
|
31
|
+
* store's own product: every field below is written by `create`/`update`/
|
|
32
|
+
* `attachArtifact` and read by `toWireTask`. Naming it is what lets a reader see
|
|
33
|
+
* the row's contract, and it is the difference between the type checker
|
|
34
|
+
* reporting "you read a field this store never sets" and reporting nothing.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} TaskRow
|
|
37
|
+
* @property {string} id - `task-<uuid>`.
|
|
38
|
+
* @property {string} contextId - A2A conversation context.
|
|
39
|
+
* @property {string} state - internal state name (`submitted`, `working`, …).
|
|
40
|
+
* @property {string} [note] - one-line status text surfaced as `status.message`.
|
|
41
|
+
* @property {object[]} artifacts - 1.0 artifacts, appended in completion order.
|
|
42
|
+
* @property {object} [metadata] - caller metadata, echoed on the wire untouched.
|
|
43
|
+
* @property {number} createdAt - epoch ms.
|
|
44
|
+
* @property {number} updatedAt - epoch ms; also drives the TTL.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Protobuf enum names, as A2A 1.0 spells task states.
|
|
49
|
+
*
|
|
50
|
+
* Exposed as a frozen lookup so callers can compare against `TASK_STATES.DONE`
|
|
51
|
+
* without hard-coding strings; the value is what goes on the wire.
|
|
52
|
+
*/
|
|
53
|
+
export const TASK_STATES = Object.freeze({
|
|
54
|
+
UNSPECIFIED: 'TASK_STATE_UNSPECIFIED',
|
|
55
|
+
SUBMITTED: 'TASK_STATE_SUBMITTED',
|
|
56
|
+
WORKING: 'TASK_STATE_WORKING',
|
|
57
|
+
INPUT_REQUIRED: 'TASK_STATE_INPUT_REQUIRED',
|
|
58
|
+
AUTH_REQUIRED: 'TASK_STATE_AUTH_REQUIRED',
|
|
59
|
+
COMPLETED: 'TASK_STATE_COMPLETED',
|
|
60
|
+
CANCELED: 'TASK_STATE_CANCELED',
|
|
61
|
+
FAILED: 'TASK_STATE_FAILED',
|
|
62
|
+
REJECTED: 'TASK_STATE_REJECTED',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** JSON-RPC and A2A error codes, so handler and tests agree on one table. */
|
|
66
|
+
export const RPC_ERRORS = Object.freeze({
|
|
67
|
+
PARSE: -32700,
|
|
68
|
+
INVALID_REQUEST: -32600,
|
|
69
|
+
METHOD_NOT_FOUND: -32601,
|
|
70
|
+
INVALID_PARAMS: -32602,
|
|
71
|
+
INTERNAL: -32603,
|
|
72
|
+
UNAUTHENTICATED: -32001,
|
|
73
|
+
FORBIDDEN: -32002,
|
|
74
|
+
VERSION_UNSUPPORTED: -32003,
|
|
75
|
+
REJECTED: -32004,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** The one protocol version this module encodes and decodes. */
|
|
79
|
+
export const PROTOCOL_VERSION = '1.0';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The version implied by an absent `A2A-Version` header.
|
|
83
|
+
*
|
|
84
|
+
* A caller that sends no version header is a 0.3 caller by convention, and 0.3
|
|
85
|
+
* field names differ (camelCase methods, `state` strings). Accepting such a
|
|
86
|
+
* request would silently drop fields, so the server rejects it instead; naming
|
|
87
|
+
* the implied default here documents *why* "missing" is a rejection and not an
|
|
88
|
+
* allowance.
|
|
89
|
+
*/
|
|
90
|
+
export const IMPLIED_LEGACY_VERSION = '0.3';
|
|
91
|
+
|
|
92
|
+
/** Internal state names (lower kebab-case) mapped to their wire enum name. */
|
|
93
|
+
const WIRE_BY_INTERNAL = Object.freeze({
|
|
94
|
+
submitted: TASK_STATES.SUBMITTED,
|
|
95
|
+
working: TASK_STATES.WORKING,
|
|
96
|
+
'input-required': TASK_STATES.INPUT_REQUIRED,
|
|
97
|
+
'auth-required': TASK_STATES.AUTH_REQUIRED,
|
|
98
|
+
completed: TASK_STATES.COMPLETED,
|
|
99
|
+
canceled: TASK_STATES.CANCELED,
|
|
100
|
+
failed: TASK_STATES.FAILED,
|
|
101
|
+
rejected: TASK_STATES.REJECTED,
|
|
102
|
+
unknown: TASK_STATES.UNSPECIFIED,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** Reverse table, derived so the two spellings can never drift apart. */
|
|
106
|
+
const INTERNAL_BY_WIRE = Object.freeze(
|
|
107
|
+
Object.fromEntries(Object.entries(WIRE_BY_INTERNAL).map(([internal, wire]) => [wire, internal])),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** States after which a task will not change again on its own. */
|
|
111
|
+
const TERMINAL_STATES = Object.freeze(new Set(['completed', 'canceled', 'failed', 'rejected']));
|
|
112
|
+
|
|
113
|
+
/** Default task lifetime; a peer that polls hours later gets a clean "gone". */
|
|
114
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
115
|
+
|
|
116
|
+
/** Default cap on retained tasks; keeps an unbounded poller from eating memory. */
|
|
117
|
+
const DEFAULT_LIMIT = 500;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Convert one internal state name to its wire enum name.
|
|
121
|
+
*
|
|
122
|
+
* Idempotent on wire input: a caller that passes `TASK_STATE_WORKING` gets it
|
|
123
|
+
* back unchanged. That tolerance is deliberate — response builders assemble
|
|
124
|
+
* values from several sources and re-normalising must not corrupt them.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|undefined} internal - internal state, wire enum, or anything else.
|
|
127
|
+
* @returns {string} a `TASK_STATE_*` name; unknown input becomes `_UNSPECIFIED`.
|
|
128
|
+
*/
|
|
129
|
+
export function toWireState(internal) {
|
|
130
|
+
if (typeof internal !== 'string') return TASK_STATES.UNSPECIFIED;
|
|
131
|
+
const direct = WIRE_BY_INTERNAL[internal];
|
|
132
|
+
if (direct !== undefined) return direct;
|
|
133
|
+
if (Object.hasOwn(INTERNAL_BY_WIRE, internal)) return internal;
|
|
134
|
+
return TASK_STATES.UNSPECIFIED;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert one wire enum name back to the internal state name.
|
|
139
|
+
*
|
|
140
|
+
* @param {string|undefined} wire - a `TASK_STATE_*` name.
|
|
141
|
+
* @returns {string} internal state name, or `'unknown'` for anything unrecognised.
|
|
142
|
+
*/
|
|
143
|
+
export function fromWireState(wire) {
|
|
144
|
+
if (typeof wire !== 'string') return 'unknown';
|
|
145
|
+
return INTERNAL_BY_WIRE[wire] ?? 'unknown';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a successful JSON-RPC response.
|
|
150
|
+
*
|
|
151
|
+
* @param {string|number|null} id - the request id, echoed verbatim.
|
|
152
|
+
* @param {object} payload - exactly one of `task` / `message` / `statusUpdate` / `artifactUpdate`.
|
|
153
|
+
* @returns {{jsonrpc: '2.0', id: string|number|null, result: object}} the envelope.
|
|
154
|
+
*/
|
|
155
|
+
export function rpcResult(id, payload) {
|
|
156
|
+
return { jsonrpc: '2.0', id: id ?? null, result: payload ?? {} };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a JSON-RPC error response.
|
|
161
|
+
*
|
|
162
|
+
* `data` stays optional at the top level but is always an object when present,
|
|
163
|
+
* because A2A clients read `data.code` for stable sub-codes; a string there
|
|
164
|
+
* would make them parse free text.
|
|
165
|
+
*
|
|
166
|
+
* @param {string|number|null} id - the request id, or null when unparseable.
|
|
167
|
+
* @param {number} code - JSON-RPC / A2A error code.
|
|
168
|
+
* @param {string} message - human sentence, never carrying a credential.
|
|
169
|
+
* @param {object} [data] - structured detail, e.g. `{code: 'rate_limited'}`.
|
|
170
|
+
* @returns {{jsonrpc: '2.0', id: string|number|null, error: object}} the envelope.
|
|
171
|
+
*/
|
|
172
|
+
export function rpcError(id, code, message, data) {
|
|
173
|
+
const error = { code: typeof code === 'number' ? code : RPC_ERRORS.INTERNAL, message: String(message ?? '') };
|
|
174
|
+
if (data !== undefined && data !== null) error.data = data;
|
|
175
|
+
return { jsonrpc: '2.0', id: id ?? null, error };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Concatenate the text parts of a 1.0 message.
|
|
180
|
+
*
|
|
181
|
+
* Non-text parts (files, structured data) are skipped rather than stringified:
|
|
182
|
+
* a skill that only wants prose should not receive `[object Object]` because a
|
|
183
|
+
* caller attached a file. Parts are joined with a newline so multi-part text
|
|
184
|
+
* keeps its paragraph structure.
|
|
185
|
+
*
|
|
186
|
+
* @param {object|undefined} message - `{parts: [{kind, text}]}` or anything else.
|
|
187
|
+
* @returns {string} joined text, `''` when there is none.
|
|
188
|
+
*/
|
|
189
|
+
export function textOfMessage(message) {
|
|
190
|
+
const parts = message?.parts;
|
|
191
|
+
if (!Array.isArray(parts)) return '';
|
|
192
|
+
const texts = [];
|
|
193
|
+
for (const part of parts) {
|
|
194
|
+
if (part === null || typeof part !== 'object') continue;
|
|
195
|
+
if (part.kind !== 'text') continue;
|
|
196
|
+
if (typeof part.text !== 'string' || part.text === '') continue;
|
|
197
|
+
texts.push(part.text);
|
|
198
|
+
}
|
|
199
|
+
return texts.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build a 1.0 message value.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} input - message fields.
|
|
206
|
+
* @param {'user'|'agent'} [input.role] - who authored it.
|
|
207
|
+
* @param {string} [input.text] - text body.
|
|
208
|
+
* @param {string} [input.messageId] - stable id; generated when omitted.
|
|
209
|
+
* @param {string} [input.contextId] - conversation context.
|
|
210
|
+
* @param {object} [input.metadata] - opaque caller metadata.
|
|
211
|
+
* @returns {object} a `{role, messageId, parts, contextId?, metadata?}` value.
|
|
212
|
+
*/
|
|
213
|
+
export function makeMessage({ role = 'agent', text = '', messageId, contextId, metadata } = {}) {
|
|
214
|
+
const message = {
|
|
215
|
+
role,
|
|
216
|
+
messageId: typeof messageId === 'string' && messageId !== '' ? messageId : randomUUID(),
|
|
217
|
+
parts: [{ kind: 'text', text: String(text ?? '') }],
|
|
218
|
+
};
|
|
219
|
+
if (typeof contextId === 'string' && contextId !== '') message.contextId = contextId;
|
|
220
|
+
if (metadata !== undefined && metadata !== null) message.metadata = metadata;
|
|
221
|
+
return message;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build a 1.0 artifact with one text part.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} input - artifact fields.
|
|
228
|
+
* @param {string} [input.artifactId] - stable id; generated when omitted.
|
|
229
|
+
* @param {string} [input.name] - short display name, e.g. `reply`.
|
|
230
|
+
* @param {string} [input.text] - artifact body.
|
|
231
|
+
* @param {object[]} [input.parts] - pre-built parts; wins over `text` when given.
|
|
232
|
+
* @returns {{artifactId: string, name: string, parts: object[]}} the artifact.
|
|
233
|
+
*/
|
|
234
|
+
export function makeArtifact({ artifactId, name = 'reply', text = '', parts } = {}) {
|
|
235
|
+
const resolvedParts = Array.isArray(parts) && parts.length > 0 ? parts : [{ kind: 'text', text: String(text ?? '') }];
|
|
236
|
+
return {
|
|
237
|
+
artifactId: typeof artifactId === 'string' && artifactId !== '' ? artifactId : `artifact-${randomUUID()}`,
|
|
238
|
+
name: typeof name === 'string' && name !== '' ? name : 'reply',
|
|
239
|
+
parts: resolvedParts,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build one streaming `statusUpdate` payload.
|
|
245
|
+
*
|
|
246
|
+
* The `final: true` flag on the last frame is the only end-of-stream signal A2A
|
|
247
|
+
* 1.0 defines; it is emitted from the same builder as the intermediate frames so
|
|
248
|
+
* a handler cannot forget it.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} input - frame fields.
|
|
251
|
+
* @param {string} [input.taskId] - task this frame belongs to.
|
|
252
|
+
* @param {string} [input.contextId] - conversation context.
|
|
253
|
+
* @param {string|undefined} [input.state] - internal state name or wire enum.
|
|
254
|
+
* @param {string} [input.note] - text surfaced as `status.message`.
|
|
255
|
+
* @param {boolean} [input.final] - whether this frame ends the stream.
|
|
256
|
+
* @param {number} [input.at] - epoch ms override, for deterministic tests.
|
|
257
|
+
* @returns {object} `{taskId, contextId?, status:{state, timestamp, message?}, final}`.
|
|
258
|
+
*/
|
|
259
|
+
export function statusUpdate({ taskId, contextId, state, note, final = false, at } = {}) {
|
|
260
|
+
const status = { state: toWireState(state), timestamp: isoAt(at) };
|
|
261
|
+
if (typeof note === 'string' && note !== '') status.message = makeMessage({ role: 'agent', text: note });
|
|
262
|
+
const frame = { taskId, status, final: final === true };
|
|
263
|
+
if (typeof contextId === 'string' && contextId !== '') frame.contextId = contextId;
|
|
264
|
+
return frame;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build one streaming `artifactUpdate` payload.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} input - frame fields.
|
|
271
|
+
* @param {string} [input.taskId] - task this frame belongs to.
|
|
272
|
+
* @param {string} [input.contextId] - conversation context.
|
|
273
|
+
* @param {object} [input.artifact] - artifact value from {@link makeArtifact}.
|
|
274
|
+
* @param {boolean} [input.append] - append to the previous chunk instead of replacing.
|
|
275
|
+
* @param {boolean} [input.lastChunk] - whether this is the artifact's final chunk.
|
|
276
|
+
* @returns {object} `{taskId, contextId?, artifact, append, lastChunk}`.
|
|
277
|
+
*/
|
|
278
|
+
export function artifactUpdate({ taskId, contextId, artifact, append = false, lastChunk = true } = {}) {
|
|
279
|
+
const frame = { taskId, artifact, append: append === true, lastChunk: lastChunk === true };
|
|
280
|
+
if (typeof contextId === 'string' && contextId !== '') frame.contextId = contextId;
|
|
281
|
+
return frame;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Serialize one task for the wire.
|
|
286
|
+
*
|
|
287
|
+
* Epoch milliseconds become ISO strings here and only here: a peer parses
|
|
288
|
+
* timestamps with `Date.parse`, and an epoch number would read as year 1970 in
|
|
289
|
+
* every A2A client that follows the schema.
|
|
290
|
+
*
|
|
291
|
+
* @param {TaskRow} task - a task row from {@link TaskStore}.
|
|
292
|
+
* @returns {object} `{id, contextId?, status, artifacts}`.
|
|
293
|
+
*/
|
|
294
|
+
export function toWireTask(task) {
|
|
295
|
+
if (task === null || typeof task !== 'object') return undefined;
|
|
296
|
+
const status = { state: toWireState(task.state), timestamp: isoAt(task.updatedAt) };
|
|
297
|
+
if (typeof task.note === 'string' && task.note !== '') status.message = makeMessage({ role: 'agent', text: task.note });
|
|
298
|
+
const wire = { id: task.id, status };
|
|
299
|
+
if (typeof task.contextId === 'string' && task.contextId !== '') wire.contextId = task.contextId;
|
|
300
|
+
wire.artifacts = Array.isArray(task.artifacts) ? task.artifacts : [];
|
|
301
|
+
if (task.metadata !== undefined && task.metadata !== null) wire.metadata = task.metadata;
|
|
302
|
+
return wire;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Set the SSE response headers exactly once.
|
|
307
|
+
*
|
|
308
|
+
* `no-transform` keeps a reverse proxy from buffering the body, and
|
|
309
|
+
* `X-Accel-Buffering: no` does the same for nginx specifically. Both matter:
|
|
310
|
+
* buffering turns a live stream into one late blob and breaks every
|
|
311
|
+
* `group/subscribe` caller. The flush is what makes the client see headers
|
|
312
|
+
* before the first frame, so an error before the first frame can still be a
|
|
313
|
+
* clean HTTP status instead of a truncated stream.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} res - a `node:http` `ServerResponse`.
|
|
316
|
+
* @returns {void}
|
|
317
|
+
*/
|
|
318
|
+
export function writeSseHead(res) {
|
|
319
|
+
if (res === null || typeof res !== 'object') return;
|
|
320
|
+
if (res.headersSent !== true && typeof res.setHeader === 'function') {
|
|
321
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
322
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
323
|
+
res.setHeader('Connection', 'keep-alive');
|
|
324
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
325
|
+
}
|
|
326
|
+
if (typeof res.flushHeaders === 'function' && res.headersSent !== true) res.flushHeaders();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Write one SSE `data:` frame.
|
|
331
|
+
*
|
|
332
|
+
* The payload is the whole JSON-RPC result object (`{result:{statusUpdate}}`),
|
|
333
|
+
* matching what A2A 1.0 gateways emit; callers never pre-serialize.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} res - a `node:http` `ServerResponse`.
|
|
336
|
+
* @param {object} payload - JSON-serializable frame body.
|
|
337
|
+
* @returns {boolean} `false` when the socket is gone, so a loop can stop early.
|
|
338
|
+
*/
|
|
339
|
+
export function writeSse(res, payload) {
|
|
340
|
+
if (res === null || typeof res !== 'object') return false;
|
|
341
|
+
if (res.writableEnded === true || res.destroyed === true) return false;
|
|
342
|
+
try {
|
|
343
|
+
return res.write(`data: ${JSON.stringify(payload)}\n\n`) !== false;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Write one SSE comment line, used as a keep-alive heartbeat.
|
|
351
|
+
*
|
|
352
|
+
* A comment is the only SSE construct a client must ignore, which is exactly
|
|
353
|
+
* what a heartbeat needs to be: it keeps NAT and proxies from closing an idle
|
|
354
|
+
* subscription without inventing a frame the client would have to understand.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} res - a `node:http` `ServerResponse`.
|
|
357
|
+
* @param {string} text - comment body; newlines are folded to spaces.
|
|
358
|
+
* @returns {boolean} `false` when the socket is gone.
|
|
359
|
+
*/
|
|
360
|
+
export function writeComment(res, text) {
|
|
361
|
+
if (res === null || typeof res !== 'object') return false;
|
|
362
|
+
if (res.writableEnded === true || res.destroyed === true) return false;
|
|
363
|
+
const body = String(text ?? '').replace(/[\r\n]+/g, ' ');
|
|
364
|
+
try {
|
|
365
|
+
return res.write(`: ${body}\n\n`) !== false;
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Render one credential as a stable, non-reversible log token.
|
|
373
|
+
*
|
|
374
|
+
* `sha256:xxxxxxxx` is enough to correlate "the same key was rejected twice"
|
|
375
|
+
* without ever putting the key in a log line, an error message, or a card.
|
|
376
|
+
*
|
|
377
|
+
* @param {unknown} value - credential or any value to mask.
|
|
378
|
+
* @returns {string} `sha256:` plus 8 hex characters, or `''` for empty input.
|
|
379
|
+
*/
|
|
380
|
+
export function redact(value) {
|
|
381
|
+
if (value === undefined || value === null || value === '') return '';
|
|
382
|
+
const text = typeof value === 'string' ? value : safeStringify(value);
|
|
383
|
+
if (text === '') return '';
|
|
384
|
+
return `sha256:${createHash('sha256').update(text).digest('hex').slice(0, 8)}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Replace every occurrence of the given secrets inside free text.
|
|
389
|
+
*
|
|
390
|
+
* Upstream errors are the usual leak: a library echoes the failing header back
|
|
391
|
+
* in its message. Scrubbing before logging is cheaper than auditing every
|
|
392
|
+
* library, so callers pass this over any text that came from outside.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} text - text about to be logged.
|
|
395
|
+
* @param {Array<string|undefined|null>} secrets - values that must never appear.
|
|
396
|
+
* @returns {string} text with each non-empty secret replaced by its digest.
|
|
397
|
+
*/
|
|
398
|
+
export function redactAll(text, secrets) {
|
|
399
|
+
let result = String(text ?? '');
|
|
400
|
+
if (!Array.isArray(secrets)) return result;
|
|
401
|
+
for (const secret of secrets) {
|
|
402
|
+
if (typeof secret !== 'string' || secret === '') continue;
|
|
403
|
+
result = result.split(secret).join(redact(secret));
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Compare a presented credential with the configured one in constant time.
|
|
410
|
+
*
|
|
411
|
+
* Both sides are hashed first so the comparison length is fixed: comparing raw
|
|
412
|
+
* strings with `===` leaks the secret's length through timing, and
|
|
413
|
+
* `timingSafeEqual` on unequal lengths throws instead of returning false.
|
|
414
|
+
*
|
|
415
|
+
* @param {string|undefined|null} presented - credential from the request.
|
|
416
|
+
* @param {string|undefined|null} expected - configured credential.
|
|
417
|
+
* @returns {boolean} true when they match and `expected` is non-empty.
|
|
418
|
+
*/
|
|
419
|
+
export function safeEqual(presented, expected) {
|
|
420
|
+
if (typeof expected !== 'string' || expected === '') return false;
|
|
421
|
+
if (typeof presented !== 'string' || presented === '') return false;
|
|
422
|
+
const a = createHash('sha256').update(presented).digest();
|
|
423
|
+
const b = createHash('sha256').update(expected).digest();
|
|
424
|
+
return timingSafeEqual(a, b);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* In-memory task store with optional JSON persistence and a TTL.
|
|
429
|
+
*
|
|
430
|
+
* The store is deliberately dumb: it holds rows, timestamps them, and notifies
|
|
431
|
+
* subscribers. It never runs a skill and never touches the network, so a test
|
|
432
|
+
* can drive it with an injected clock and no I/O at all.
|
|
433
|
+
*
|
|
434
|
+
* Task rows are internal (`state` in lower kebab-case, times in epoch ms). Use
|
|
435
|
+
* {@link toWireTask} at the edge rather than exposing rows to a caller.
|
|
436
|
+
*/
|
|
437
|
+
export class TaskStore {
|
|
438
|
+
/** @type {Map<string, object>} id → task row, insertion ordered. */
|
|
439
|
+
#tasks = new Map();
|
|
440
|
+
|
|
441
|
+
/** @type {Map<string, number>} id → monotonic creation counter, for stable ordering. */
|
|
442
|
+
#seq = new Map();
|
|
443
|
+
|
|
444
|
+
/** @type {Set<(task: object) => void>} change subscribers. */
|
|
445
|
+
#listeners = new Set();
|
|
446
|
+
|
|
447
|
+
#ttlMs;
|
|
448
|
+
#limit;
|
|
449
|
+
#persistPath;
|
|
450
|
+
#clock;
|
|
451
|
+
#counter = 0;
|
|
452
|
+
#lastFlushError = null;
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {object} [options] - store options.
|
|
456
|
+
* @param {number} [options.ttlMs] - task lifetime in ms; default 24 h.
|
|
457
|
+
* @param {string} [options.persistPath] - JSON file to load from and flush to.
|
|
458
|
+
* @param {() => number} [options.clock] - epoch-ms source; default `Date.now`.
|
|
459
|
+
* @param {number} [options.limit] - max retained tasks; oldest are evicted.
|
|
460
|
+
*/
|
|
461
|
+
constructor({ ttlMs = DEFAULT_TTL_MS, persistPath, clock, limit = DEFAULT_LIMIT } = {}) {
|
|
462
|
+
this.#ttlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : DEFAULT_TTL_MS;
|
|
463
|
+
this.#limit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_LIMIT;
|
|
464
|
+
this.#persistPath = typeof persistPath === 'string' && persistPath !== '' ? persistPath : undefined;
|
|
465
|
+
this.#clock = typeof clock === 'function' ? clock : Date.now;
|
|
466
|
+
this.#restore();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** @returns {string|undefined} the file this store flushes to, when configured. */
|
|
470
|
+
get persistPath() {
|
|
471
|
+
return this.#persistPath;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** @returns {Error|null} the last flush failure, for diagnostics only. */
|
|
475
|
+
get lastFlushError() {
|
|
476
|
+
return this.#lastFlushError;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** @returns {number} number of rows currently held (expired rows included). */
|
|
480
|
+
get size() {
|
|
481
|
+
return this.#tasks.size;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Create one task in the `submitted` state.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} [input] - creation fields.
|
|
488
|
+
* @param {string} [input.contextId] - conversation context; generated when omitted.
|
|
489
|
+
* @param {object} [input.metadata] - opaque metadata echoed on the wire.
|
|
490
|
+
* @returns {object} the new task row.
|
|
491
|
+
*/
|
|
492
|
+
create({ contextId, metadata } = {}) {
|
|
493
|
+
this.#sweep();
|
|
494
|
+
const at = this.#now();
|
|
495
|
+
const id = `task-${randomUUID()}`;
|
|
496
|
+
this.#counter += 1;
|
|
497
|
+
const task = {
|
|
498
|
+
id,
|
|
499
|
+
contextId: typeof contextId === 'string' && contextId !== '' ? contextId : randomUUID(),
|
|
500
|
+
state: 'submitted',
|
|
501
|
+
note: undefined,
|
|
502
|
+
artifacts: [],
|
|
503
|
+
createdAt: at,
|
|
504
|
+
updatedAt: at,
|
|
505
|
+
};
|
|
506
|
+
if (metadata !== undefined && metadata !== null) task.metadata = metadata;
|
|
507
|
+
this.#tasks.set(id, task);
|
|
508
|
+
this.#seq.set(id, this.#counter);
|
|
509
|
+
this.#evict();
|
|
510
|
+
this.#emit(task);
|
|
511
|
+
return task;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Update a task's state and/or note.
|
|
516
|
+
*
|
|
517
|
+
* An omitted `state` is left alone rather than reset, so a caller can attach a
|
|
518
|
+
* note without owning the state machine.
|
|
519
|
+
*
|
|
520
|
+
* @param {string} id - task id.
|
|
521
|
+
* @param {object} [patch] - `{state?, note?}`.
|
|
522
|
+
* @returns {object|undefined} the updated row, or `undefined` for an unknown id.
|
|
523
|
+
*/
|
|
524
|
+
update(id, { state, note } = {}) {
|
|
525
|
+
const task = this.#live(id);
|
|
526
|
+
if (task === undefined) return undefined;
|
|
527
|
+
if (typeof state === 'string' && state !== '') task.state = state;
|
|
528
|
+
if (note !== undefined) task.note = typeof note === 'string' ? note : undefined;
|
|
529
|
+
task.updatedAt = this.#now();
|
|
530
|
+
this.#emit(task);
|
|
531
|
+
return task;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Attach one artifact to a task.
|
|
536
|
+
*
|
|
537
|
+
* @param {string} id - task id.
|
|
538
|
+
* @param {object} artifact - artifact value; a missing `artifactId` is filled in.
|
|
539
|
+
* @returns {object|undefined} the updated row, or `undefined` for an unknown id.
|
|
540
|
+
*/
|
|
541
|
+
attachArtifact(id, artifact) {
|
|
542
|
+
const task = this.#live(id);
|
|
543
|
+
if (task === undefined) return undefined;
|
|
544
|
+
if (artifact === null || typeof artifact !== 'object') return task;
|
|
545
|
+
const normalized = typeof artifact.artifactId === 'string' && artifact.artifactId !== ''
|
|
546
|
+
? artifact
|
|
547
|
+
: { ...artifact, artifactId: `artifact-${randomUUID()}` };
|
|
548
|
+
task.artifacts.push(normalized);
|
|
549
|
+
task.updatedAt = this.#now();
|
|
550
|
+
this.#emit(task);
|
|
551
|
+
return task;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Read one task, dropping it first if its TTL has elapsed.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} id - task id.
|
|
558
|
+
* @returns {object|undefined} the row, or `undefined` when unknown or expired.
|
|
559
|
+
*/
|
|
560
|
+
get(id) {
|
|
561
|
+
return this.#live(id);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Cancel a task.
|
|
566
|
+
*
|
|
567
|
+
* `changed` distinguishes "we cancelled it" from "it was already finished",
|
|
568
|
+
* which is the difference an A2A caller uses to decide whether its side
|
|
569
|
+
* effects still matter.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} id - task id.
|
|
572
|
+
* @returns {{task: object|undefined, changed: boolean}} the outcome.
|
|
573
|
+
*/
|
|
574
|
+
cancel(id) {
|
|
575
|
+
const task = this.#live(id);
|
|
576
|
+
if (task === undefined) return { task: undefined, changed: false };
|
|
577
|
+
if (TERMINAL_STATES.has(task.state)) return { task, changed: false };
|
|
578
|
+
task.state = 'canceled';
|
|
579
|
+
task.updatedAt = this.#now();
|
|
580
|
+
this.#emit(task);
|
|
581
|
+
return { task, changed: true };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* List tasks, newest first.
|
|
586
|
+
*
|
|
587
|
+
* @param {object} [options] - list options.
|
|
588
|
+
* @param {number} [options.limit] - max rows; defaults to every retained row.
|
|
589
|
+
* @returns {object[]} task rows ordered by creation, newest first.
|
|
590
|
+
*/
|
|
591
|
+
list({ limit } = {}) {
|
|
592
|
+
this.#sweep();
|
|
593
|
+
const rows = [...this.#tasks.values()].sort((a, b) => (this.#seq.get(b.id) ?? 0) - (this.#seq.get(a.id) ?? 0));
|
|
594
|
+
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : rows.length;
|
|
595
|
+
return rows.slice(0, cap);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Subscribe to task changes.
|
|
600
|
+
*
|
|
601
|
+
* The listener sees the live row, not a copy. That is on purpose: a
|
|
602
|
+
* `group/subscribe` style consumer wants the current object identity to track
|
|
603
|
+
* later mutations, and every consumer in this plugin only reads.
|
|
604
|
+
*
|
|
605
|
+
* @param {(task: object) => void} fn - change callback.
|
|
606
|
+
* @returns {() => void} disposer; calling it twice is harmless.
|
|
607
|
+
*/
|
|
608
|
+
onChange(fn) {
|
|
609
|
+
if (typeof fn !== 'function') return () => {};
|
|
610
|
+
const listener = (task) => {
|
|
611
|
+
try {
|
|
612
|
+
fn(task);
|
|
613
|
+
} catch {
|
|
614
|
+
// A broken subscriber must not be able to fail a task transition.
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
this.#listeners.add(listener);
|
|
618
|
+
return () => {
|
|
619
|
+
this.#listeners.delete(listener);
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Persist every retained task to `persistPath`, best effort.
|
|
625
|
+
*
|
|
626
|
+
* Writes go through a temp file plus rename so a crash mid-write cannot leave
|
|
627
|
+
* a half-parsed JSON file that the next start would refuse to load. Failures
|
|
628
|
+
* are recorded rather than thrown: the caller has already answered the peer.
|
|
629
|
+
*
|
|
630
|
+
* @returns {boolean} true when the write landed (or there is nothing to write).
|
|
631
|
+
*/
|
|
632
|
+
flush() {
|
|
633
|
+
if (this.#persistPath === undefined) return true;
|
|
634
|
+
try {
|
|
635
|
+
const rows = [...this.#tasks.values()];
|
|
636
|
+
const payload = JSON.stringify({ version: 1, savedAt: this.#now(), tasks: rows }, null, 2);
|
|
637
|
+
const dir = dirname(this.#persistPath);
|
|
638
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
639
|
+
const tmp = `${this.#persistPath}.${process.pid}.${Date.now()}.tmp`;
|
|
640
|
+
writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
641
|
+
try {
|
|
642
|
+
renameSync(tmp, this.#persistPath);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
// Windows refuses a rename over an open file; fall back to a direct write.
|
|
645
|
+
rmSync(tmp, { force: true });
|
|
646
|
+
writeFileSync(this.#persistPath, payload, { encoding: 'utf8', mode: 0o600 });
|
|
647
|
+
if (error instanceof Error && this.#lastFlushError === null) this.#lastFlushError = error;
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
this.#lastFlushError = error instanceof Error ? error : new Error(String(error));
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Drop every task and every subscriber; used by tests and by plugin teardown. */
|
|
657
|
+
clear() {
|
|
658
|
+
this.#tasks.clear();
|
|
659
|
+
this.#seq.clear();
|
|
660
|
+
this.#listeners.clear();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Current epoch ms from the injected clock. */
|
|
664
|
+
#now() {
|
|
665
|
+
const value = this.#clock();
|
|
666
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Resolve a live row, expiring it first when its TTL has elapsed.
|
|
671
|
+
* @param {string} id - task id.
|
|
672
|
+
* @returns {object|undefined} the row.
|
|
673
|
+
*/
|
|
674
|
+
#live(id) {
|
|
675
|
+
if (typeof id !== 'string' || id === '') return undefined;
|
|
676
|
+
const task = this.#tasks.get(id);
|
|
677
|
+
if (task === undefined) return undefined;
|
|
678
|
+
if (this.#now() - task.updatedAt > this.#ttlMs) {
|
|
679
|
+
this.#tasks.delete(id);
|
|
680
|
+
this.#seq.delete(id);
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
return task;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Remove every row whose TTL has elapsed. */
|
|
687
|
+
#sweep() {
|
|
688
|
+
const at = this.#now();
|
|
689
|
+
for (const [id, task] of this.#tasks) {
|
|
690
|
+
if (at - task.updatedAt > this.#ttlMs) {
|
|
691
|
+
this.#tasks.delete(id);
|
|
692
|
+
this.#seq.delete(id);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Drop the oldest rows once the retained count exceeds the configured cap. */
|
|
698
|
+
#evict() {
|
|
699
|
+
while (this.#tasks.size > this.#limit) {
|
|
700
|
+
let oldestId;
|
|
701
|
+
let oldestSeq = Number.POSITIVE_INFINITY;
|
|
702
|
+
for (const [id, seq] of this.#seq) {
|
|
703
|
+
if (seq < oldestSeq) {
|
|
704
|
+
oldestSeq = seq;
|
|
705
|
+
oldestId = id;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (oldestId === undefined) break;
|
|
709
|
+
this.#tasks.delete(oldestId);
|
|
710
|
+
this.#seq.delete(oldestId);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Notify every subscriber about one row. */
|
|
715
|
+
#emit(task) {
|
|
716
|
+
for (const listener of this.#listeners) listener(task);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Load rows from `persistPath` when the file exists and parses; never throws. */
|
|
720
|
+
#restore() {
|
|
721
|
+
if (this.#persistPath === undefined || !existsSync(this.#persistPath)) return;
|
|
722
|
+
try {
|
|
723
|
+
const raw = readFileSync(this.#persistPath, 'utf8');
|
|
724
|
+
const parsed = JSON.parse(raw);
|
|
725
|
+
const rows = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
|
|
726
|
+
for (const row of rows) {
|
|
727
|
+
if (row === null || typeof row !== 'object') continue;
|
|
728
|
+
if (typeof row.id !== 'string' || row.id === '') continue;
|
|
729
|
+
this.#counter += 1;
|
|
730
|
+
this.#tasks.set(row.id, {
|
|
731
|
+
id: row.id,
|
|
732
|
+
contextId: typeof row.contextId === 'string' ? row.contextId : randomUUID(),
|
|
733
|
+
state: typeof row.state === 'string' && row.state !== '' ? row.state : 'submitted',
|
|
734
|
+
note: typeof row.note === 'string' ? row.note : undefined,
|
|
735
|
+
artifacts: Array.isArray(row.artifacts) ? row.artifacts : [],
|
|
736
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : this.#now(),
|
|
737
|
+
updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : this.#now(),
|
|
738
|
+
...row.metadata === undefined ? {} : { metadata: row.metadata },
|
|
739
|
+
});
|
|
740
|
+
this.#seq.set(row.id, this.#counter);
|
|
741
|
+
}
|
|
742
|
+
this.#sweep();
|
|
743
|
+
} catch {
|
|
744
|
+
// A corrupt store must not stop the plugin from starting; start empty.
|
|
745
|
+
this.#tasks.clear();
|
|
746
|
+
this.#seq.clear();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Format epoch ms as the ISO string the wire expects.
|
|
753
|
+
* @param {number|undefined} at - epoch ms; defaults to now.
|
|
754
|
+
* @returns {string} ISO-8601 timestamp.
|
|
755
|
+
*/
|
|
756
|
+
function isoAt(at) {
|
|
757
|
+
const value = Number.isFinite(at) ? at : Date.now();
|
|
758
|
+
return new Date(value).toISOString();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* `JSON.stringify` that never throws and never yields `undefined`.
|
|
763
|
+
* @param {unknown} value - value to render.
|
|
764
|
+
* @returns {string} JSON text, or `String(value)` for cyclic input.
|
|
765
|
+
*/
|
|
766
|
+
function safeStringify(value) {
|
|
767
|
+
try {
|
|
768
|
+
return JSON.stringify(value) ?? '';
|
|
769
|
+
} catch {
|
|
770
|
+
return String(value);
|
|
771
|
+
}
|
|
772
|
+
}
|