@cryptolibertus/pi-peer 0.3.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/LICENSE +21 -0
- package/README.md +70 -0
- package/extensions/pi-peer/index.ts +753 -0
- package/package.json +58 -0
- package/src/peers/command.mjs +289 -0
- package/src/peers/comms.mjs +676 -0
- package/src/peers/config.mjs +356 -0
- package/src/peers/extension-lifecycle.mjs +21 -0
- package/src/peers/goal-board.mjs +528 -0
- package/src/peers/guidance.mjs +45 -0
- package/src/peers/inbound-bridge.mjs +240 -0
- package/src/peers/local-transport.mjs +814 -0
- package/src/peers/message-store.mjs +114 -0
- package/src/peers/protocol.mjs +256 -0
- package/src/peers/role-collaboration-demo.mjs +71 -0
- package/src/peers/runtime.mjs +200 -0
- package/src/peers/status.mjs +158 -0
- package/src/peers/tool-results.mjs +154 -0
- package/src/utils.mjs +83 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PEER_VERSION,
|
|
3
|
+
assertValidPeerEnvelope,
|
|
4
|
+
createPeerEnvelope,
|
|
5
|
+
isPeerProtocolCompatible,
|
|
6
|
+
normalizePeerAddress,
|
|
7
|
+
normalizePeerMessageResponseBody,
|
|
8
|
+
normalizePeerMessageSendBody,
|
|
9
|
+
redactPeerAuditValue,
|
|
10
|
+
validatePeerEnvelope,
|
|
11
|
+
} from "./protocol.mjs";
|
|
12
|
+
|
|
13
|
+
export const HOP_LIMIT_ERROR_CODE = "PI_PEER_HOP_LIMIT_EXCEEDED";
|
|
14
|
+
export const SELF_SEND_ERROR_CODE = "PI_PEER_SELF_TARGET";
|
|
15
|
+
export const UNSUPPORTED_TRANSPORT_ERROR_CODE = "PI_PEER_UNSUPPORTED_TRANSPORT";
|
|
16
|
+
|
|
17
|
+
export class PeerCommsError extends Error {
|
|
18
|
+
constructor(message, code, details = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "PeerCommsError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MemoryPeerRegistry {
|
|
27
|
+
#peers = new Map();
|
|
28
|
+
|
|
29
|
+
constructor(initialPeers = []) {
|
|
30
|
+
for (const peer of initialPeers) this.#peers.set(peer.peerId, normalizePeerDescriptor(peer));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async listPeers(filter = {}) {
|
|
34
|
+
let peers = [...this.#peers.values()].map((peer) => ({ ...peer, capabilities: clone(peer.capabilities || {}) }));
|
|
35
|
+
if (filter.transport) peers = peers.filter((peer) => peer.transport === filter.transport);
|
|
36
|
+
if (filter.trust) peers = peers.filter((peer) => peer.trust === filter.trust);
|
|
37
|
+
return peers.map(publicPeerDescriptor);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getPeer(peerId, options = {}) {
|
|
41
|
+
const peer = this.#peers.get(peerId);
|
|
42
|
+
if (!peer) return undefined;
|
|
43
|
+
const copy = { ...peer, capabilities: clone(peer.capabilities || {}) };
|
|
44
|
+
return options.includeSecrets === true ? copy : publicPeerDescriptor(copy);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async registerPeer(peer) {
|
|
48
|
+
const normalized = normalizePeerDescriptor(peer);
|
|
49
|
+
this.#peers.set(normalized.peerId, normalized);
|
|
50
|
+
return publicPeerDescriptor(normalized);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async unregisterPeer(peerId) {
|
|
54
|
+
this.#peers.delete(peerId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class InMemoryPeerTransport {
|
|
59
|
+
#responders = new Map();
|
|
60
|
+
#defaultResponder;
|
|
61
|
+
|
|
62
|
+
constructor(options = {}) {
|
|
63
|
+
this.#defaultResponder = options.defaultResponder || defaultPromptResponder;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
registerResponder(peerId, responder) {
|
|
67
|
+
this.#responders.set(peerId, responder);
|
|
68
|
+
return () => this.#responders.delete(peerId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async send(envelope, peer, context = {}) {
|
|
72
|
+
assertValidPeerEnvelope(envelope);
|
|
73
|
+
const responder = this.#responders.get(peer.peerId) || this.#defaultResponder;
|
|
74
|
+
const responseBody = normalizePeerMessageResponseBody(await responder(envelope, peer, context));
|
|
75
|
+
return createPeerEnvelope({
|
|
76
|
+
type: "message.response",
|
|
77
|
+
conversationId: envelope.conversationId,
|
|
78
|
+
source: envelope.target,
|
|
79
|
+
target: envelope.source,
|
|
80
|
+
correlationId: envelope.id,
|
|
81
|
+
causationId: envelope.id,
|
|
82
|
+
hopCount: envelope.hopCount,
|
|
83
|
+
maxHopCount: envelope.maxHopCount,
|
|
84
|
+
body: responseBody,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createPeerComms(options = {}) {
|
|
90
|
+
return new PeerComms(options);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class PeerComms {
|
|
94
|
+
#registry;
|
|
95
|
+
#transport;
|
|
96
|
+
#localAddress;
|
|
97
|
+
#homeDir;
|
|
98
|
+
#messages = new Map();
|
|
99
|
+
#conversations = new Map();
|
|
100
|
+
#pending = new Map();
|
|
101
|
+
#audit = [];
|
|
102
|
+
#listeners = new Set();
|
|
103
|
+
#auditSink;
|
|
104
|
+
#messageStore;
|
|
105
|
+
#supportedTransports;
|
|
106
|
+
#disposed = false;
|
|
107
|
+
|
|
108
|
+
constructor({
|
|
109
|
+
registry = new MemoryPeerRegistry(),
|
|
110
|
+
transport = new InMemoryPeerTransport(),
|
|
111
|
+
localPeerId = "local",
|
|
112
|
+
localAddress = {},
|
|
113
|
+
homeDir = process.env.HOME || "",
|
|
114
|
+
auditSink,
|
|
115
|
+
messageStore,
|
|
116
|
+
persistedState,
|
|
117
|
+
supportedTransports = ["coms"],
|
|
118
|
+
} = {}) {
|
|
119
|
+
this.#registry = registry;
|
|
120
|
+
this.#transport = transport;
|
|
121
|
+
this.#localAddress = normalizePeerAddress({ peerId: localPeerId, transport: "coms", ...localAddress });
|
|
122
|
+
this.#homeDir = homeDir;
|
|
123
|
+
this.#auditSink = typeof auditSink === "function" ? auditSink : undefined;
|
|
124
|
+
this.#messageStore = messageStore && typeof messageStore.save === "function" ? messageStore : undefined;
|
|
125
|
+
this.#supportedTransports = new Set(supportedTransports);
|
|
126
|
+
this.#hydratePersistedState(persistedState);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async listPeers(filter) {
|
|
130
|
+
this.#ensureActive();
|
|
131
|
+
const peers = await this.#registry.listPeers(filter);
|
|
132
|
+
return peers.map((peer) => this.#annotatePeerDescriptor(peer));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async getPeer(peerId) {
|
|
136
|
+
this.#ensureActive();
|
|
137
|
+
const peer = await this.#registry.getPeer(peerId);
|
|
138
|
+
return peer ? this.#annotatePeerDescriptor(peer) : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async registerPeer(peer) {
|
|
142
|
+
this.#ensureActive();
|
|
143
|
+
return this.#registry.registerPeer(peer);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async unregisterPeer(peerId) {
|
|
147
|
+
this.#ensureActive();
|
|
148
|
+
return this.#registry.unregisterPeer(peerId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async sendMessage(peerId, message, options = {}) {
|
|
152
|
+
this.#ensureActive();
|
|
153
|
+
const peer = await this.#registry.getPeer(peerId, { includeSecrets: true });
|
|
154
|
+
if (!peer) throw new PeerCommsError(`Unknown peer '${peerId}'`, "PI_PEER_UNKNOWN_PEER", { peerId });
|
|
155
|
+
if (peer.trust === "disabled") throw new PeerCommsError(`Peer '${peerId}' is disabled`, "PI_PEER_DISABLED", { peerId });
|
|
156
|
+
if (this.#isSelfPeer(peer.peerId) && options.allowSelf !== true) {
|
|
157
|
+
const error = new PeerCommsError(`Peer '${peerId}' is the current peer (${this.#localAddress.peerId}); self-targeting does not create an independent peer response. Choose another peer or pass allowSelf: true.`, SELF_SEND_ERROR_CODE, { peerId, localPeerId: this.#localAddress.peerId, allowSelf: false });
|
|
158
|
+
this.#recordAudit({ kind: "message.error", peerId: peer.peerId, transport: peer.transport, status: "error", error: error.message, code: SELF_SEND_ERROR_CODE });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
if (!this.#supportedTransports.has(peer.transport)) {
|
|
162
|
+
const error = new PeerCommsError(`Peer transport '${peer.transport}' is not enabled in this prototype`, UNSUPPORTED_TRANSPORT_ERROR_CODE, { peerId, transport: peer.transport });
|
|
163
|
+
this.#recordAudit({ kind: "message.error", peerId, transport: peer.transport, status: "error", error: error.message });
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const hopCount = Number.isInteger(options.hopCount) ? options.hopCount : 0;
|
|
168
|
+
const maxHopCount = Number.isInteger(options.maxHopCount) ? options.maxHopCount : Number.isInteger(peer.maxHopCount) ? peer.maxHopCount : 1;
|
|
169
|
+
if (hopCount >= maxHopCount) {
|
|
170
|
+
const error = new PeerCommsError("Peer message hop limit reached before dispatch", HOP_LIMIT_ERROR_CODE, { hopCount, maxHopCount, peerId });
|
|
171
|
+
this.#recordAudit({ kind: "message.error", peerId, transport: peer.transport, status: "error", error: error.message, hopCount, maxHopCount });
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const body = normalizePeerMessageSendBody(message);
|
|
176
|
+
const target = normalizePeerAddress({ peerId: peer.peerId, transport: peer.transport, cwd: peer.cwd, role: peer.role });
|
|
177
|
+
const request = createPeerEnvelope({
|
|
178
|
+
type: "message.send",
|
|
179
|
+
conversationId: options.conversationId,
|
|
180
|
+
source: this.#localAddress,
|
|
181
|
+
target,
|
|
182
|
+
hopCount,
|
|
183
|
+
maxHopCount,
|
|
184
|
+
audit: options.audit,
|
|
185
|
+
body,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const snapshot = {
|
|
189
|
+
messageId: request.id,
|
|
190
|
+
conversationId: request.conversationId,
|
|
191
|
+
peerId: peer.peerId,
|
|
192
|
+
status: "queued",
|
|
193
|
+
request,
|
|
194
|
+
response: null,
|
|
195
|
+
responseEnvelope: null,
|
|
196
|
+
events: [],
|
|
197
|
+
error: null,
|
|
198
|
+
createdAt: request.timestamp,
|
|
199
|
+
updatedAt: request.timestamp,
|
|
200
|
+
};
|
|
201
|
+
this.#appendMessageEvent(snapshot, { type: "queued", status: "queued", summary: `Queued for ${peer.peerId}` }, { updateTimestamp: false });
|
|
202
|
+
this.#messages.set(snapshot.messageId, snapshot);
|
|
203
|
+
this.#upsertConversation(snapshot);
|
|
204
|
+
this.#recordAudit({ kind: "message.send", peerId: peer.peerId, transport: peer.transport, status: "queued", messageId: snapshot.messageId, conversationId: snapshot.conversationId, body });
|
|
205
|
+
this.#emit({ type: "message.queued", message: this.#cloneMessage(snapshot) });
|
|
206
|
+
this.#persistMessageState();
|
|
207
|
+
|
|
208
|
+
const responsePromise = this.#dispatch(snapshot.messageId, peer);
|
|
209
|
+
this.#pending.set(snapshot.messageId, responsePromise);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
messageId: snapshot.messageId,
|
|
213
|
+
conversationId: snapshot.conversationId,
|
|
214
|
+
peerId: peer.peerId,
|
|
215
|
+
get status() {
|
|
216
|
+
return snapshot.status;
|
|
217
|
+
},
|
|
218
|
+
response: responsePromise,
|
|
219
|
+
cancel: async (reason) => this.cancelMessage(snapshot.messageId, reason),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async getMessage(messageId) {
|
|
224
|
+
this.#ensureActive();
|
|
225
|
+
const snapshot = this.#messages.get(messageId);
|
|
226
|
+
return snapshot ? this.#cloneMessage(snapshot) : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getConversation(conversationId) {
|
|
230
|
+
this.#ensureActive();
|
|
231
|
+
const conversation = this.#conversations.get(conversationId);
|
|
232
|
+
return conversation ? clone(conversation) : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async listMessages(filter = {}) {
|
|
236
|
+
this.#ensureActive();
|
|
237
|
+
let messages = [...this.#messages.values()].map((message) => this.#cloneMessage(message));
|
|
238
|
+
if (filter.status) messages = messages.filter((message) => message.status === filter.status);
|
|
239
|
+
if (filter.peerId) messages = messages.filter((message) => message.peerId === filter.peerId);
|
|
240
|
+
return messages;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async listConversations(filter = {}) {
|
|
244
|
+
this.#ensureActive();
|
|
245
|
+
let conversations = [...this.#conversations.values()].map((conversation) => clone(conversation));
|
|
246
|
+
if (filter.status) conversations = conversations.filter((conversation) => conversation.status === filter.status);
|
|
247
|
+
if (filter.peerId) conversations = conversations.filter((conversation) => conversation.peerIds?.includes(filter.peerId));
|
|
248
|
+
return conversations;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async resumeMessage(messageId, options = {}) {
|
|
252
|
+
this.#ensureActive();
|
|
253
|
+
const snapshot = this.#messages.get(messageId);
|
|
254
|
+
if (!snapshot) throw new PeerCommsError(`Unknown peer message '${messageId}'`, "PI_PEER_UNKNOWN_MESSAGE", { messageId });
|
|
255
|
+
if (snapshot.response && snapshot.status === "responded") return this.#messageHandle(snapshot, Promise.resolve(clone(snapshot.response)));
|
|
256
|
+
if (["queued", "running"].includes(snapshot.status) && this.#pending.has(messageId)) return this.#messageHandle(snapshot, this.#pending.get(messageId));
|
|
257
|
+
if (snapshot.status !== "disconnected") throw new PeerCommsError(`Peer message '${messageId}' is not disconnected and cannot be resumed`, "PI_PEER_NOT_RESUMABLE", { messageId, status: snapshot.status });
|
|
258
|
+
const peer = await this.#registry.getPeer(snapshot.peerId, { includeSecrets: true });
|
|
259
|
+
if (!peer) throw new PeerCommsError(`Unknown peer '${snapshot.peerId}'`, "PI_PEER_UNKNOWN_PEER", { peerId: snapshot.peerId, messageId });
|
|
260
|
+
snapshot.status = "queued";
|
|
261
|
+
snapshot.updatedAt = new Date().toISOString();
|
|
262
|
+
this.#appendMessageEvent(snapshot, { type: "resumed", status: "queued", summary: `Resumed disconnected message for ${snapshot.peerId}` }, { updateTimestamp: false });
|
|
263
|
+
this.#upsertConversation(snapshot);
|
|
264
|
+
this.#recordAudit({ kind: "message.resume", peerId: snapshot.peerId, transport: snapshot.request?.target?.transport || peer.transport, status: "queued", messageId, conversationId: snapshot.conversationId });
|
|
265
|
+
this.#emit({ type: "message.resumed", message: this.#cloneMessage(snapshot) });
|
|
266
|
+
this.#persistMessageState();
|
|
267
|
+
const responsePromise = this.#dispatch(messageId, peer, { resumed: true, allowSelf: options.allowSelf === true });
|
|
268
|
+
this.#pending.set(messageId, responsePromise);
|
|
269
|
+
return this.#messageHandle(snapshot, responsePromise);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async listTasks(filter = {}) {
|
|
273
|
+
this.#ensureActive();
|
|
274
|
+
let messages = [...this.#messages.values()];
|
|
275
|
+
if (filter.active === true) messages = messages.filter((message) => ["queued", "running"].includes(message.status));
|
|
276
|
+
if (filter.status) messages = messages.filter((message) => message.status === filter.status);
|
|
277
|
+
if (filter.peerId) messages = messages.filter((message) => message.peerId === filter.peerId);
|
|
278
|
+
return messages.map((message) => this.#messageTaskSummary(message));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async get(id) {
|
|
282
|
+
this.#ensureActive();
|
|
283
|
+
return (await this.getMessage(id)) || (await this.getConversation(id)) || (await this.getPeer(id));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async awaitMessage(messageId, options = {}) {
|
|
287
|
+
this.#ensureActive();
|
|
288
|
+
const snapshot = this.#messages.get(messageId);
|
|
289
|
+
if (!snapshot) throw new PeerCommsError(`Unknown peer message '${messageId}'`, "PI_PEER_UNKNOWN_MESSAGE", { messageId });
|
|
290
|
+
if (snapshot.response) return clone(snapshot.response);
|
|
291
|
+
const pending = this.#pending.get(messageId);
|
|
292
|
+
if (!pending) throw new PeerCommsError(`Peer message '${messageId}' is not pending`, "PI_PEER_NOT_PENDING", { messageId });
|
|
293
|
+
try {
|
|
294
|
+
const response = options.timeoutMs ? await withTimeout(pending, options.timeoutMs, messageId) : await pending;
|
|
295
|
+
return clone(response);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error?.code === "PI_PEER_AWAIT_TIMEOUT") {
|
|
298
|
+
this.#annotateAwaitTimeout(snapshot, options.timeoutMs);
|
|
299
|
+
error.details = {
|
|
300
|
+
...(error.details || {}),
|
|
301
|
+
timedOut: true,
|
|
302
|
+
taskStillRunning: ["queued", "running"].includes(snapshot.status),
|
|
303
|
+
messageId: snapshot.messageId,
|
|
304
|
+
conversationId: snapshot.conversationId,
|
|
305
|
+
peerId: snapshot.peerId,
|
|
306
|
+
status: snapshot.status,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async recordMessageEvent(messageId, event = {}) {
|
|
314
|
+
this.#ensureActive();
|
|
315
|
+
const snapshot = this.#messages.get(messageId);
|
|
316
|
+
if (!snapshot) return undefined;
|
|
317
|
+
this.#appendMessageEvent(snapshot, event);
|
|
318
|
+
this.#upsertConversation(snapshot);
|
|
319
|
+
this.#recordAudit({ kind: "message.event", peerId: snapshot.peerId, transport: snapshot.request.target.transport, status: snapshot.status, messageId, conversationId: snapshot.conversationId, event });
|
|
320
|
+
this.#emit({ type: "message.event", message: this.#cloneMessage(snapshot), event: clone(event) });
|
|
321
|
+
this.#persistMessageState();
|
|
322
|
+
return this.#cloneMessage(snapshot);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async cancelMessage(messageId, reason = "cancelled by sender") {
|
|
326
|
+
this.#ensureActive();
|
|
327
|
+
const snapshot = this.#messages.get(messageId);
|
|
328
|
+
if (!snapshot) throw new PeerCommsError(`Unknown peer message '${messageId}'`, "PI_PEER_UNKNOWN_MESSAGE", { messageId });
|
|
329
|
+
if (["responded", "cancelled", "error"].includes(snapshot.status)) return;
|
|
330
|
+
snapshot.status = "cancelled";
|
|
331
|
+
snapshot.updatedAt = new Date().toISOString();
|
|
332
|
+
snapshot.response = { status: "CANCELLED", summary: reason };
|
|
333
|
+
this.#appendMessageEvent(snapshot, { type: "cancelled", status: "cancelled", summary: reason }, { updateTimestamp: false });
|
|
334
|
+
this.#upsertConversation(snapshot);
|
|
335
|
+
this.#recordAudit({ kind: "message.cancel", peerId: snapshot.peerId, transport: snapshot.request.target.transport, status: "cancelled", messageId, conversationId: snapshot.conversationId, reason });
|
|
336
|
+
this.#emit({ type: "message.cancelled", message: this.#cloneMessage(snapshot) });
|
|
337
|
+
this.#persistMessageState();
|
|
338
|
+
return this.#cloneMessage(snapshot);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async getAuditEntries() {
|
|
342
|
+
this.#ensureActive();
|
|
343
|
+
return clone(this.#audit);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
subscribe(listener) {
|
|
347
|
+
this.#listeners.add(listener);
|
|
348
|
+
return () => this.#listeners.delete(listener);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async dispose() {
|
|
352
|
+
await this.#messageStore?.flush?.().catch(() => {});
|
|
353
|
+
this.#disposed = true;
|
|
354
|
+
this.#listeners.clear();
|
|
355
|
+
this.#pending.clear();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async #dispatch(messageId, peer) {
|
|
359
|
+
const snapshot = this.#messages.get(messageId);
|
|
360
|
+
if (!snapshot || snapshot.status === "cancelled") return snapshot?.response || { status: "CANCELLED" };
|
|
361
|
+
snapshot.status = "running";
|
|
362
|
+
snapshot.updatedAt = new Date().toISOString();
|
|
363
|
+
this.#appendMessageEvent(snapshot, { type: "running", status: "running", summary: `Dispatched to ${peer.peerId}` }, { updateTimestamp: false });
|
|
364
|
+
this.#upsertConversation(snapshot);
|
|
365
|
+
this.#recordAudit({ kind: "message.accepted", peerId: peer.peerId, transport: peer.transport, status: "running", messageId, conversationId: snapshot.conversationId });
|
|
366
|
+
this.#emit({ type: "message.running", message: this.#cloneMessage(snapshot) });
|
|
367
|
+
this.#persistMessageState();
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const responseEnvelope = await this.#transport.send(snapshot.request, peer, { comms: this });
|
|
371
|
+
if (snapshot.status === "cancelled") return clone(snapshot.response || { status: "CANCELLED" });
|
|
372
|
+
const validation = validatePeerEnvelope(responseEnvelope);
|
|
373
|
+
if (!validation.ok) throw new PeerCommsError(`Invalid peer response envelope: ${validation.errors.join("; ")}`, "PI_PEER_INVALID_RESPONSE", { errors: validation.errors });
|
|
374
|
+
if (responseEnvelope.type !== "message.response" || responseEnvelope.correlationId !== snapshot.request.id) {
|
|
375
|
+
throw new PeerCommsError("Peer response did not correlate to the request", "PI_PEER_RESPONSE_MISMATCH", { messageId });
|
|
376
|
+
}
|
|
377
|
+
snapshot.status = "responded";
|
|
378
|
+
snapshot.response = attachPeerResponseIdentity(normalizePeerMessageResponseBody(responseEnvelope.body), peer, { homeDir: this.#homeDir });
|
|
379
|
+
snapshot.responseEnvelope = responseEnvelope;
|
|
380
|
+
snapshot.updatedAt = new Date().toISOString();
|
|
381
|
+
this.#appendMessageEvent(snapshot, { type: "responded", status: "responded", summary: snapshot.response.summary || snapshot.response.status }, { updateTimestamp: false });
|
|
382
|
+
this.#upsertConversation(snapshot);
|
|
383
|
+
this.#recordAudit({ kind: "message.response", peerId: peer.peerId, transport: peer.transport, status: snapshot.response.status, messageId, conversationId: snapshot.conversationId, body: snapshot.response });
|
|
384
|
+
this.#emit({ type: "message.responded", message: this.#cloneMessage(snapshot) });
|
|
385
|
+
this.#persistMessageState();
|
|
386
|
+
return clone(snapshot.response);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
snapshot.status = "error";
|
|
389
|
+
snapshot.error = { message: error.message, code: error.code || "PI_PEER_TRANSPORT_ERROR" };
|
|
390
|
+
snapshot.response = { status: "ERROR", summary: error.message };
|
|
391
|
+
snapshot.updatedAt = new Date().toISOString();
|
|
392
|
+
this.#appendMessageEvent(snapshot, { type: "error", status: "error", summary: error.message, code: snapshot.error.code }, { updateTimestamp: false });
|
|
393
|
+
this.#upsertConversation(snapshot);
|
|
394
|
+
this.#recordAudit({ kind: "message.error", peerId: peer.peerId, transport: peer.transport, status: "error", messageId, conversationId: snapshot.conversationId, error: snapshot.error });
|
|
395
|
+
this.#emit({ type: "message.error", message: this.#cloneMessage(snapshot) });
|
|
396
|
+
this.#persistMessageState();
|
|
397
|
+
return clone(snapshot.response);
|
|
398
|
+
} finally {
|
|
399
|
+
this.#pending.delete(messageId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#hydratePersistedState(state = {}) {
|
|
404
|
+
const messages = Array.isArray(state?.messages) ? state.messages : [];
|
|
405
|
+
const conversations = Array.isArray(state?.conversations) ? state.conversations : [];
|
|
406
|
+
const recoveredAt = new Date().toISOString();
|
|
407
|
+
for (const conversation of conversations) {
|
|
408
|
+
if (conversation?.conversationId) this.#conversations.set(conversation.conversationId, clone(conversation));
|
|
409
|
+
}
|
|
410
|
+
for (const raw of messages) {
|
|
411
|
+
if (!raw?.messageId || !raw?.conversationId) continue;
|
|
412
|
+
const snapshot = {
|
|
413
|
+
...clone(raw),
|
|
414
|
+
response: raw.response || null,
|
|
415
|
+
responseEnvelope: raw.responseEnvelope || null,
|
|
416
|
+
events: Array.isArray(raw.events) ? clone(raw.events).slice(-50) : [],
|
|
417
|
+
error: raw.error || null,
|
|
418
|
+
};
|
|
419
|
+
if (["queued", "running"].includes(snapshot.status)) {
|
|
420
|
+
snapshot.status = "disconnected";
|
|
421
|
+
snapshot.updatedAt = recoveredAt;
|
|
422
|
+
snapshot.recoveredAt = recoveredAt;
|
|
423
|
+
this.#appendMessageEvent(snapshot, {
|
|
424
|
+
type: "recovered.disconnected",
|
|
425
|
+
status: "disconnected",
|
|
426
|
+
summary: "Recovered from local message store without a live pending transport",
|
|
427
|
+
}, { updateTimestamp: false });
|
|
428
|
+
}
|
|
429
|
+
this.#messages.set(snapshot.messageId, snapshot);
|
|
430
|
+
this.#upsertConversation(snapshot);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#persistMessageState() {
|
|
435
|
+
if (!this.#messageStore) return;
|
|
436
|
+
const state = {
|
|
437
|
+
version: 1,
|
|
438
|
+
updatedAt: new Date().toISOString(),
|
|
439
|
+
messages: [...this.#messages.values()].map((message) => this.#cloneMessage(message)),
|
|
440
|
+
conversations: [...this.#conversations.values()].map((conversation) => clone(conversation)),
|
|
441
|
+
};
|
|
442
|
+
try {
|
|
443
|
+
const result = this.#messageStore.save(state);
|
|
444
|
+
if (result && typeof result.catch === "function") result.catch(() => {});
|
|
445
|
+
} catch {
|
|
446
|
+
// Message persistence is best-effort and must not alter message delivery.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#upsertConversation(snapshot) {
|
|
451
|
+
const conversation = this.#conversations.get(snapshot.conversationId) || {
|
|
452
|
+
conversationId: snapshot.conversationId,
|
|
453
|
+
peerIds: [],
|
|
454
|
+
messageIds: [],
|
|
455
|
+
status: "running",
|
|
456
|
+
createdAt: snapshot.createdAt,
|
|
457
|
+
updatedAt: snapshot.updatedAt,
|
|
458
|
+
};
|
|
459
|
+
if (!conversation.peerIds.includes(snapshot.peerId)) conversation.peerIds.push(snapshot.peerId);
|
|
460
|
+
if (!conversation.messageIds.includes(snapshot.messageId)) conversation.messageIds.push(snapshot.messageId);
|
|
461
|
+
conversation.status = snapshot.status;
|
|
462
|
+
conversation.updatedAt = snapshot.updatedAt;
|
|
463
|
+
this.#conversations.set(snapshot.conversationId, conversation);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#annotateAwaitTimeout(snapshot, timeoutMs) {
|
|
467
|
+
const summary = `Await timed out after ${timeoutMs}ms; peer task status is ${snapshot.status}`;
|
|
468
|
+
this.#appendMessageEvent(snapshot, { type: "await.timeout", status: snapshot.status, summary, timeoutMs });
|
|
469
|
+
this.#upsertConversation(snapshot);
|
|
470
|
+
this.#recordAudit({ kind: "message.await.timeout", peerId: snapshot.peerId, transport: snapshot.request.target.transport, status: snapshot.status, messageId: snapshot.messageId, conversationId: snapshot.conversationId, timeoutMs });
|
|
471
|
+
this.#emit({ type: "message.await.timeout", message: this.#cloneMessage(snapshot) });
|
|
472
|
+
this.#persistMessageState();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#appendMessageEvent(snapshot, event = {}, options = {}) {
|
|
476
|
+
const at = new Date().toISOString();
|
|
477
|
+
const normalized = redactPeerAuditValue({
|
|
478
|
+
at,
|
|
479
|
+
type: typeof event.type === "string" && event.type.trim() ? event.type.trim() : "progress",
|
|
480
|
+
...(typeof event.status === "string" && event.status.trim() ? { status: event.status.trim() } : {}),
|
|
481
|
+
...(typeof event.summary === "string" && event.summary.trim() ? { summary: event.summary.trim() } : {}),
|
|
482
|
+
...(event.code !== undefined ? { code: event.code } : {}),
|
|
483
|
+
...(event.timeoutMs !== undefined ? { timeoutMs: event.timeoutMs } : {}),
|
|
484
|
+
...(typeof event.phase === "string" && event.phase.trim() ? { phase: event.phase.trim() } : {}),
|
|
485
|
+
...(event.detail !== undefined ? { detail: event.detail } : {}),
|
|
486
|
+
}, { homeDir: this.#homeDir });
|
|
487
|
+
snapshot.events.push(normalized);
|
|
488
|
+
snapshot.events = snapshot.events.slice(-50);
|
|
489
|
+
snapshot.lastEvent = normalized;
|
|
490
|
+
if (normalized.type === "heartbeat" || normalized.type === "request.queued" || normalized.type === "request.active") snapshot.lastHeartbeatAt = normalized.at;
|
|
491
|
+
if (options.updateTimestamp !== false) snapshot.updatedAt = at;
|
|
492
|
+
return normalized;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#annotatePeerDescriptor(peer) {
|
|
496
|
+
const annotated = { ...peer };
|
|
497
|
+
if (this.#isSelfPeer(peer.peerId)) {
|
|
498
|
+
annotated.self = true;
|
|
499
|
+
annotated.current = true;
|
|
500
|
+
annotated.identity = buildPeerIdentity(peer, { homeDir: this.#homeDir });
|
|
501
|
+
}
|
|
502
|
+
return annotated;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#isSelfPeer(peerId) {
|
|
506
|
+
return typeof peerId === "string" && peerId === this.#localAddress.peerId;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#messageHandle(snapshot, responsePromise) {
|
|
510
|
+
return {
|
|
511
|
+
messageId: snapshot.messageId,
|
|
512
|
+
conversationId: snapshot.conversationId,
|
|
513
|
+
peerId: snapshot.peerId,
|
|
514
|
+
get status() {
|
|
515
|
+
return snapshot.status;
|
|
516
|
+
},
|
|
517
|
+
response: responsePromise,
|
|
518
|
+
cancel: async (reason) => this.cancelMessage(snapshot.messageId, reason),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#messageTaskSummary(message) {
|
|
523
|
+
const body = message.request?.body || {};
|
|
524
|
+
const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {};
|
|
525
|
+
return {
|
|
526
|
+
messageId: message.messageId,
|
|
527
|
+
conversationId: message.conversationId,
|
|
528
|
+
peerId: message.peerId,
|
|
529
|
+
status: message.status,
|
|
530
|
+
active: ["queued", "running"].includes(message.status),
|
|
531
|
+
intent: body.intent || "ask",
|
|
532
|
+
claimedPaths: Array.isArray(metadata.claimedPaths) ? metadata.claimedPaths.filter((item) => typeof item === "string") : [],
|
|
533
|
+
goalId: typeof metadata.goalId === "string" ? metadata.goalId : undefined,
|
|
534
|
+
goalClaimId: typeof metadata.goalClaimId === "string" ? metadata.goalClaimId : undefined,
|
|
535
|
+
createdAt: message.createdAt,
|
|
536
|
+
updatedAt: message.updatedAt,
|
|
537
|
+
lastHeartbeatAt: message.lastHeartbeatAt,
|
|
538
|
+
lastEvent: message.lastEvent,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#recordAudit(entry) {
|
|
543
|
+
const redacted = redactPeerAuditValue({ at: new Date().toISOString(), ...entry }, { homeDir: this.#homeDir });
|
|
544
|
+
this.#audit.unshift(redacted);
|
|
545
|
+
this.#audit = this.#audit.slice(0, 200);
|
|
546
|
+
if (this.#auditSink) {
|
|
547
|
+
try {
|
|
548
|
+
const result = this.#auditSink(redacted);
|
|
549
|
+
if (result && typeof result.catch === "function") result.catch(() => {});
|
|
550
|
+
} catch {
|
|
551
|
+
// Persistence hooks must not corrupt peer message lifecycle state.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return redacted;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#emit(event) {
|
|
558
|
+
for (const listener of this.#listeners) {
|
|
559
|
+
try {
|
|
560
|
+
listener(event);
|
|
561
|
+
} catch {
|
|
562
|
+
// Listener failures must not corrupt peer message lifecycle state.
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#cloneMessage(snapshot) {
|
|
568
|
+
return clone(snapshot);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#ensureActive() {
|
|
572
|
+
if (this.#disposed) throw new PeerCommsError("Peer comms has been disposed", "PI_PEER_DISPOSED");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function normalizePeerDescriptor(peer) {
|
|
577
|
+
if (!peer || typeof peer !== "object" || Array.isArray(peer)) throw new Error("peer descriptor must be an object");
|
|
578
|
+
if (typeof peer.peerId !== "string" || !peer.peerId.trim()) throw new Error("peer descriptor requires peerId");
|
|
579
|
+
const manifest = peer.manifest && typeof peer.manifest === "object" && !Array.isArray(peer.manifest) ? peer.manifest : {};
|
|
580
|
+
const merged = { ...manifest, ...peer, capabilities: { ...(manifest.capabilities && typeof manifest.capabilities === "object" ? manifest.capabilities : {}), ...(peer.capabilities && typeof peer.capabilities === "object" ? peer.capabilities : {}) } };
|
|
581
|
+
delete merged.manifest;
|
|
582
|
+
return {
|
|
583
|
+
...merged,
|
|
584
|
+
peerId: peer.peerId.trim(),
|
|
585
|
+
transport: merged.transport || "coms",
|
|
586
|
+
trust: merged.trust || "read-only",
|
|
587
|
+
status: merged.status || "configured",
|
|
588
|
+
protocolVersion: Number.isInteger(merged.protocolVersion) ? merged.protocolVersion : Number.isInteger(merged.version) ? merged.version : PEER_VERSION,
|
|
589
|
+
minProtocolVersion: Number.isInteger(merged.minProtocolVersion) ? merged.minProtocolVersion : Number.isInteger(merged.protocolVersion) ? merged.protocolVersion : PEER_VERSION,
|
|
590
|
+
maxProtocolVersion: Number.isInteger(merged.maxProtocolVersion) ? merged.maxProtocolVersion : Number.isInteger(merged.protocolVersion) ? merged.protocolVersion : PEER_VERSION,
|
|
591
|
+
compatible: isPeerProtocolCompatible(merged),
|
|
592
|
+
capabilities: merged.capabilities,
|
|
593
|
+
maxHopCount: Number.isInteger(merged.maxHopCount) ? merged.maxHopCount : 1,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function publicPeerDescriptor(peer) {
|
|
598
|
+
const copy = { ...clone(peer), capabilities: clone(peer.capabilities || {}) };
|
|
599
|
+
const authConfigured = hasConfiguredAuth(peer);
|
|
600
|
+
delete copy.auth;
|
|
601
|
+
delete copy.authToken;
|
|
602
|
+
delete copy.authTokenEnv;
|
|
603
|
+
delete copy.agentMd;
|
|
604
|
+
delete copy.agentMdPath;
|
|
605
|
+
delete copy.agentMdContent;
|
|
606
|
+
delete copy.agentInstructions;
|
|
607
|
+
if (authConfigured) copy.authConfigured = true;
|
|
608
|
+
return copy;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function hasConfiguredAuth(peer) {
|
|
612
|
+
return peer?.authRequired === true || nonEmptyString(peer?.authToken) || nonEmptyString(peer?.authTokenEnv) || Boolean(peer?.auth);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function attachPeerResponseIdentity(response, peer, options = {}) {
|
|
616
|
+
return {
|
|
617
|
+
...response,
|
|
618
|
+
peerIdentity: buildPeerIdentity(peer, options),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function buildPeerIdentity(peer = {}, options = {}) {
|
|
623
|
+
const identity = {
|
|
624
|
+
peerId: peer.peerId,
|
|
625
|
+
transport: peer.transport || "coms",
|
|
626
|
+
trust: peer.trust || "read-only",
|
|
627
|
+
status: peer.status || "configured",
|
|
628
|
+
protocolVersion: peer.protocolVersion || PEER_VERSION,
|
|
629
|
+
compatible: peer.compatible !== false,
|
|
630
|
+
capabilities: clone(peer.capabilities || {}),
|
|
631
|
+
writeAccess: inferWriteAccess(peer),
|
|
632
|
+
};
|
|
633
|
+
if (nonEmptyString(peer.role)) identity.role = peer.role;
|
|
634
|
+
if (nonEmptyString(peer.cwd)) identity.cwd = redactPeerAuditValue(peer.cwd, { homeDir: options.homeDir || "" });
|
|
635
|
+
return identity;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function inferWriteAccess(peer = {}) {
|
|
639
|
+
const capabilities = peer.capabilities && typeof peer.capabilities === "object" ? peer.capabilities : {};
|
|
640
|
+
if (typeof capabilities.writeAccess === "boolean") return capabilities.writeAccess;
|
|
641
|
+
if (typeof capabilities.write === "boolean") return capabilities.write;
|
|
642
|
+
if (typeof capabilities.editFiles === "boolean") return capabilities.editFiles;
|
|
643
|
+
if (peer.role === "reviewer") return false;
|
|
644
|
+
if (peer.role === "worker") return true;
|
|
645
|
+
return peer.trust !== "read-only" && peer.trust !== "disabled";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function defaultPromptResponder(envelope, peer) {
|
|
649
|
+
return {
|
|
650
|
+
status: "OK",
|
|
651
|
+
finalAssistantMessage: `Peer '${peer.peerId}' received your prompt: ${envelope.body.prompt}`,
|
|
652
|
+
summary: "Local in-memory coms prototype response. Configure a real local Pi transport before relying on peer work.",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function clone(value) {
|
|
657
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function nonEmptyString(value) {
|
|
661
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function withTimeout(promise, timeoutMs, messageId) {
|
|
665
|
+
let timeout;
|
|
666
|
+
try {
|
|
667
|
+
return await Promise.race([
|
|
668
|
+
promise,
|
|
669
|
+
new Promise((_, reject) => {
|
|
670
|
+
timeout = setTimeout(() => reject(new PeerCommsError(`Timed out waiting for peer message '${messageId}'`, "PI_PEER_AWAIT_TIMEOUT", { messageId, timeoutMs })), timeoutMs);
|
|
671
|
+
}),
|
|
672
|
+
]);
|
|
673
|
+
} finally {
|
|
674
|
+
clearTimeout(timeout);
|
|
675
|
+
}
|
|
676
|
+
}
|