@sgtbeatdown/pi-tasks 0.1.0 → 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.
@@ -0,0 +1,383 @@
1
+ import {
2
+ MAX_RELAY_PAYLOAD_BYTES,
3
+ TASK_PROTOCOL_VERSION,
4
+ TaskEnvelopeKind,
5
+ TaskOutboxDeliveryError,
6
+ TaskProtocolError,
7
+ } from "./task-protocol";
8
+ import type {
9
+ RelayDelivery,
10
+ RelayEnvelope,
11
+ TaskEndpoint,
12
+ TaskEvent,
13
+ TaskIntent,
14
+ TaskRecord,
15
+ TaskRelay,
16
+ } from "./task-protocol";
17
+ import type { TaskStore } from "./task-store";
18
+
19
+ const RECEIVE_PAGE_SIZE = 100;
20
+ const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
21
+ const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
22
+ const CANONICAL_EVENTS = new Set([
23
+ "task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
24
+ "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
25
+ ]);
26
+ const RECEIVER_INTENT_TYPES = new Set<TaskIntent["type"]>([
27
+ "task.completed", "task.failed", "task.cancelled", "task.information", "task.question", "task.answer", "task.delivery_receipt",
28
+ ]);
29
+ const IGNORE_OUTBOX_FAILURES: ReadonlySet<string> = new Set();
30
+
31
+ export interface TaskCoreOptions {
32
+ readonly endpoint: TaskEndpoint;
33
+ readonly relay: TaskRelay;
34
+ readonly store: TaskStore;
35
+ readonly clock?: { readonly now: () => number };
36
+ readonly ids?: () => string;
37
+ }
38
+
39
+ export interface CreateTaskInput {
40
+ readonly target: TaskEndpoint;
41
+ readonly task: string;
42
+ readonly timeoutMs: number;
43
+ }
44
+
45
+ export interface SubmitIntentInput {
46
+ readonly taskId: string;
47
+ readonly type: TaskIntent["type"];
48
+ readonly payload: Record<string, unknown>;
49
+ }
50
+
51
+ export interface TaskCore {
52
+ connect(signal?: AbortSignal): Promise<void>;
53
+ createTask(input: CreateTaskInput, signal?: AbortSignal): Promise<{ readonly taskId: string }>;
54
+ getTask(taskId: string): TaskRecord | undefined;
55
+ listTasks(): readonly TaskRecord[];
56
+ flushOutbox(signal?: AbortSignal): Promise<void>;
57
+ receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
58
+ acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
59
+ submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
60
+ recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
61
+ evaluateTimeouts(signal?: AbortSignal): Promise<void>;
62
+ acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
63
+ }
64
+
65
+ type ReceivedEnvelope =
66
+ | { readonly kind: "assignment"; readonly task: Omit<TaskRecord, "events">; readonly event: TaskEvent }
67
+ | { readonly kind: "intent"; readonly task: TaskRecord; readonly intent: TaskIntent }
68
+ | { readonly kind: "canonical_event"; readonly task: TaskRecord; readonly event: TaskEvent };
69
+
70
+ export function createTaskCore(options: TaskCoreOptions): TaskCore {
71
+ const clock = options.clock ?? { now: (): number => Date.now() };
72
+ const ids = options.ids ?? (() => crypto.randomUUID());
73
+
74
+ return {
75
+ async connect(signal) {
76
+ const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
77
+ if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
78
+ throwIfAborted(signal);
79
+ },
80
+ async createTask(input, signal) {
81
+ if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
82
+ const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
83
+ if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
84
+ const now = clock.now();
85
+ const taskId = ids();
86
+ const task: Omit<TaskRecord, "events"> = {
87
+ taskId, protocolVersion: TASK_PROTOCOL_VERSION, origin: options.endpoint, target, task: input.task,
88
+ createdAt: now, expiresAt: now + input.timeoutMs, status: "active",
89
+ };
90
+ const created = event(task, ids(), "task.created", "1", options.endpoint, target, now, { task: input.task });
91
+ const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created });
92
+ options.store.transaction(() => {
93
+ options.store.putTask(task);
94
+ options.store.appendEvent(created);
95
+ options.store.putOutbox(assignment);
96
+ });
97
+ try {
98
+ await flush(options, signal, new Set([assignment.envelopeId]));
99
+ } catch (error) {
100
+ throw operationError(error, taskId);
101
+ }
102
+ return { taskId };
103
+ },
104
+ getTask(taskId) { return options.store.getTask(taskId); },
105
+ listTasks() { return options.store.listTasks(); },
106
+ async flushOutbox(signal) { await flush(options, signal); },
107
+ async receive(signal) {
108
+ await flush(options, signal, IGNORE_OUTBOX_FAILURES);
109
+ const page = await options.relay.receive({ endpoint: options.endpoint, cursor: options.store.getReceiveCursor(), limit: RECEIVE_PAGE_SIZE }, signal);
110
+ if (!isInboxPage(page)) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
111
+ const visibleDeliveries: RelayDelivery[] = [];
112
+ for (const delivery of page.deliveries) {
113
+ if (!isDelivery(delivery)) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid delivery");
114
+ const received = validateReceivedEnvelope(options, delivery.envelope);
115
+ let persistedEnvelopeIds: readonly string[] = [];
116
+ options.store.transaction(() => {
117
+ if (!options.store.persistInbox(delivery.envelope, delivery.cursor)) return;
118
+ persistedEnvelopeIds = persistReceivedEnvelope(options, clock.now, ids, received);
119
+ });
120
+ if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
121
+ if (received.kind === "intent") {
122
+ await this.acknowledgeRelayDelivery(delivery.cursor, signal);
123
+ continue;
124
+ }
125
+ visibleDeliveries.push(delivery);
126
+ }
127
+ return visibleDeliveries;
128
+ },
129
+ async acknowledgeRelayDelivery(cursor, signal) {
130
+ if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
131
+ await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
132
+ options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
133
+ },
134
+ async submitIntent(input, signal) {
135
+ if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
136
+ const task = requiredTask(options.store, input.taskId);
137
+ let persistedEnvelopeIds: readonly string[] = [];
138
+ options.store.transaction(() => {
139
+ persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
140
+ });
141
+ await flush(options, signal, new Set(persistedEnvelopeIds));
142
+ },
143
+ async recordInsertion(input, signal) {
144
+ const task = requiredTask(options.store, input.taskId);
145
+ let persistedEnvelopeIds: readonly string[] = [];
146
+ options.store.transaction(() => {
147
+ if (options.store.putInsertionReceipt(input.taskId, input.eventId)) {
148
+ persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, { taskId: input.taskId, type: "task.delivery_receipt", payload: { eventId: input.eventId } });
149
+ }
150
+ });
151
+ if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
152
+ },
153
+ async evaluateTimeouts(signal) {
154
+ const persistedEnvelopeIds: string[] = [];
155
+ for (const candidate of options.store.listTasks()) {
156
+ if (!sameEndpoint(candidate.origin, options.endpoint) || TERMINAL_STATUSES.has(candidate.status) || candidate.expiresAt > clock.now()) continue;
157
+ options.store.transaction(() => {
158
+ const task = options.store.getTask(candidate.taskId);
159
+ if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
160
+ persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out"));
161
+ });
162
+ }
163
+ if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
164
+ },
165
+ async acknowledgeParent(taskId, signal) {
166
+ const task = requiredTask(options.store, taskId);
167
+ if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
168
+ let persistedEnvelopeIds: readonly string[] = [];
169
+ options.store.transaction(() => {
170
+ persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged");
171
+ });
172
+ await flush(options, signal, new Set(persistedEnvelopeIds));
173
+ },
174
+ };
175
+ }
176
+
177
+ function validateReceivedEnvelope(options: TaskCoreOptions, relayEnvelope: unknown): ReceivedEnvelope {
178
+ if (!isEnvelope(relayEnvelope)) throw new TaskProtocolError("INVALID_ENVELOPE", "relay envelope is malformed");
179
+ if (relayEnvelope.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "received an incompatible task envelope");
180
+ if (!sameEndpoint(relayEnvelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "relay envelope targets another endpoint");
181
+ const payload = parsePayload(relayEnvelope);
182
+ if (relayEnvelope.kind === TaskEnvelopeKind.assignment) return validateAssignment(relayEnvelope, payload);
183
+ if (relayEnvelope.kind === TaskEnvelopeKind.intent) return validateIntent(options, relayEnvelope, payload);
184
+ return validateCanonicalEvent(options, relayEnvelope, payload);
185
+ }
186
+
187
+ function validateAssignment(envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
188
+ if (!isRecord(payload) || !isTaskRecordInput(payload.task) || !isTaskEvent(payload.event)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment payload is malformed");
189
+ const task = payload.task;
190
+ const created = payload.event;
191
+ if (envelope.taskId !== task.taskId || envelope.taskId !== created.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
192
+ || task.status !== "active" || task.expiresAt < task.createdAt || created.type !== "task.created" || created.sequence !== "1" || created.payload.task !== task.task
193
+ || !sameEndpoint(envelope.source, task.origin) || !sameEndpoint(envelope.target, task.target)
194
+ || !sameEndpoint(created.source, task.origin) || !sameEndpoint(created.target, task.target)) {
195
+ throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment envelope headers do not match its payload");
196
+ }
197
+ return { kind: "assignment", task, event: created };
198
+ }
199
+
200
+ function validateIntent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
201
+ const task = requiredTask(options.store, envelope.taskId);
202
+ if (!sameEndpoint(task.origin, options.endpoint) || !sameEndpoint(envelope.source, task.target) || !sameEndpoint(envelope.target, task.origin)) {
203
+ throw new TaskProtocolError("UNAUTHORIZED_INTENT", "only the assigned receiver may submit an intent to origin");
204
+ }
205
+ if (!isTaskIntent(payload) || payload.taskId !== envelope.taskId || !RECEIVER_INTENT_TYPES.has(payload.type)) {
206
+ throw new TaskProtocolError("INVALID_INTENT", "intent envelope headers or payload are invalid");
207
+ }
208
+ return { kind: "intent", task, intent: payload };
209
+ }
210
+
211
+ function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
212
+ const task = requiredTask(options.store, envelope.taskId);
213
+ if (!isTaskEvent(payload) || !CANONICAL_EVENTS.has(payload.type) || payload.taskId !== envelope.taskId
214
+ || !sameEndpoint(envelope.source, task.origin) || (!sameEndpoint(envelope.target, task.target) && !sameEndpoint(envelope.target, task.origin))
215
+ || !sameEndpoint(payload.source, task.origin) || !sameEndpoint(payload.target, task.target)) {
216
+ throw new TaskProtocolError("INVALID_EVENT", "canonical event envelope headers or payload are invalid");
217
+ }
218
+ if (!sameEndpoint(envelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "canonical event targets another endpoint");
219
+ return { kind: "canonical_event", task, event: payload };
220
+ }
221
+
222
+ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope): readonly string[] {
223
+ if (received.kind === "assignment") {
224
+ options.store.putTask(received.task);
225
+ options.store.appendEvent(received.event);
226
+ return [];
227
+ }
228
+ if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type);
229
+ if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
230
+ return [];
231
+ }
232
+
233
+ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput): readonly string[] {
234
+ if (sameEndpoint(options.endpoint, task.origin)) {
235
+ return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type);
236
+ }
237
+ const envelopeId = ids();
238
+ const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
239
+ options.store.putIntent(intent.intentId, input.taskId, envelopeId);
240
+ options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
241
+ return [envelopeId];
242
+ }
243
+
244
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string): readonly string[] {
245
+ const terminal = TERMINAL_EVENTS.has(requestedType);
246
+ const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
247
+ const sequence = String(task.events.length + 1);
248
+ const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
249
+ const persistedEnvelopeIds: string[] = [];
250
+ options.store.appendEvent(canonical);
251
+ if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
252
+ const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
253
+ options.store.putOutbox(targetEnvelope);
254
+ persistedEnvelopeIds.push(targetEnvelope.envelopeId);
255
+ if (!sameEndpoint(task.origin, task.target)) {
256
+ const originEnvelope = envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
257
+ options.store.putOutbox(originEnvelope);
258
+ persistedEnvelopeIds.push(originEnvelope.envelopeId);
259
+ }
260
+ return persistedEnvelopeIds;
261
+ }
262
+
263
+ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
264
+ const failures: Array<{ readonly envelopeId: string; readonly error: unknown }> = [];
265
+ for (const record of options.store.outbox("pending")) {
266
+ try {
267
+ await options.relay.send(record.envelope, signal);
268
+ options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
269
+ } catch (error) {
270
+ if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
271
+ failures.push({ envelopeId: record.envelope.envelopeId, error });
272
+ }
273
+ }
274
+ const reportedFailure = failures.find((failure) => reportedEnvelopeIds === undefined || reportedEnvelopeIds.has(failure.envelopeId));
275
+ if (reportedFailure) throw outboxDeliveryError(reportedFailure.error);
276
+ }
277
+
278
+ function outboxDeliveryError(error: unknown): TaskOutboxDeliveryError {
279
+ if (error instanceof TaskProtocolError) {
280
+ return new TaskOutboxDeliveryError(error.code, error.message, { retryable: error.retryable, details: error.details });
281
+ }
282
+ return new TaskOutboxDeliveryError("TASK_ERROR", error instanceof Error ? error.message : "task delivery failed");
283
+ }
284
+
285
+ function operationError(error: unknown, taskId: string): TaskProtocolError {
286
+ if (error instanceof TaskProtocolError) {
287
+ return new TaskProtocolError(error.code, error.message, { retryable: error.retryable, details: { ...error.details, taskId } });
288
+ }
289
+ return new TaskProtocolError("TASK_ERROR", error instanceof Error ? error.message : "task operation failed", { details: { taskId } });
290
+ }
291
+
292
+ function event(task: Omit<TaskRecord, "events">, eventId: string, type: string, sequence: string, source: TaskEndpoint, target: TaskEndpoint, occurredAt: number, payload: Record<string, unknown>): TaskEvent {
293
+ return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
294
+ }
295
+
296
+ function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown): RelayEnvelope {
297
+ return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload) };
298
+ }
299
+
300
+ function parsePayload(envelope: RelayEnvelope): unknown {
301
+ try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
302
+ }
303
+
304
+ function requiredTask(store: TaskStore, taskId: string): TaskRecord {
305
+ const task = store.getTask(taskId);
306
+ if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`);
307
+ return task;
308
+ }
309
+
310
+ function statusFor(type: string): TaskRecord["status"] {
311
+ if (type === "task.completed") return "completed";
312
+ if (type === "task.failed") return "failed";
313
+ if (type === "task.cancelled") return "cancelled";
314
+ return "timed_out";
315
+ }
316
+
317
+ function isInboxPage(value: unknown): value is { readonly deliveries: readonly RelayDelivery[]; readonly nextCursor: string; readonly hasMore: boolean } {
318
+ return isRecord(value) && Array.isArray(value.deliveries) && decimal(value.nextCursor) && typeof value.hasMore === "boolean";
319
+ }
320
+
321
+ function isDelivery(value: unknown): value is RelayDelivery {
322
+ return isRecord(value) && decimal(value.cursor) && isEnvelope(value.envelope);
323
+ }
324
+
325
+ function isEnvelope(value: unknown): value is RelayEnvelope {
326
+ return isRecord(value) && nonEmpty(value.envelopeId) && value.protocolVersion === TASK_PROTOCOL_VERSION && nonEmpty(value.taskId)
327
+ && isEndpoint(value.source) && isEndpoint(value.target) && isEnvelopeKind(value.kind)
328
+ && typeof value.payload === "string" && new TextEncoder().encode(value.payload).byteLength <= MAX_RELAY_PAYLOAD_BYTES;
329
+ }
330
+
331
+ function isTaskRecordInput(value: unknown): value is Omit<TaskRecord, "events"> {
332
+ return isRecord(value) && nonEmpty(value.taskId) && value.protocolVersion === TASK_PROTOCOL_VERSION && isEndpoint(value.origin) && isEndpoint(value.target)
333
+ && nonEmpty(value.task) && finiteNumber(value.createdAt) && finiteNumber(value.expiresAt) && isTaskStatus(value.status);
334
+ }
335
+
336
+ function isTaskEvent(value: unknown): value is TaskEvent {
337
+ return isRecord(value) && nonEmpty(value.eventId) && nonEmpty(value.taskId) && nonEmpty(value.type) && positiveDecimal(value.sequence)
338
+ && isEndpoint(value.source) && isEndpoint(value.target) && finiteNumber(value.occurredAt) && isRecord(value.payload);
339
+ }
340
+
341
+ function isTaskIntent(value: unknown): value is TaskIntent {
342
+ return isRecord(value) && nonEmpty(value.intentId) && nonEmpty(value.taskId) && typeof value.type === "string" && isRecord(value.payload);
343
+ }
344
+
345
+ function isEndpoint(value: unknown): value is TaskEndpoint {
346
+ return isRecord(value) && nonEmpty(value.relay) && nonEmpty(value.id);
347
+ }
348
+
349
+ function isEnvelopeKind(value: unknown): value is RelayEnvelope["kind"] {
350
+ return value === TaskEnvelopeKind.assignment || value === TaskEnvelopeKind.intent || value === TaskEnvelopeKind.canonicalEvent;
351
+ }
352
+
353
+ function isTaskStatus(value: unknown): value is TaskRecord["status"] {
354
+ return value === "active" || value === "completed" || value === "failed" || value === "cancelled" || value === "timed_out";
355
+ }
356
+
357
+ function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
358
+ return left.relay === right.relay && left.id === right.id;
359
+ }
360
+
361
+ function isRecord(value: unknown): value is Record<string, unknown> {
362
+ return value !== null && typeof value === "object" && !Array.isArray(value);
363
+ }
364
+
365
+ function nonEmpty(value: unknown): value is string {
366
+ return typeof value === "string" && value.length > 0;
367
+ }
368
+
369
+ function finiteNumber(value: unknown): value is number {
370
+ return typeof value === "number" && Number.isFinite(value);
371
+ }
372
+
373
+ function decimal(value: unknown): value is string {
374
+ return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
375
+ }
376
+
377
+ function positiveDecimal(value: unknown): value is string {
378
+ return typeof value === "string" && /^[1-9][0-9]*$/.test(value);
379
+ }
380
+
381
+ function throwIfAborted(signal: AbortSignal | undefined): void {
382
+ if (signal?.aborted) throw new TaskProtocolError("ABORTED", "task relay connection was cancelled");
383
+ }
@@ -0,0 +1,109 @@
1
+ import type { TaskCore } from "./task-core";
2
+ import { TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
3
+ import type { RelayDelivery, TaskEvent } from "./task-protocol";
4
+
5
+ const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
6
+ const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
7
+
8
+ interface InboxContext {
9
+ readonly hasPendingMessages: () => boolean;
10
+ readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
11
+ }
12
+
13
+ interface InboxPi {
14
+ sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true; readonly deliverAs: "followUp" }): void;
15
+ appendEntry(customType: string, data: { readonly cursor: string }): void;
16
+ }
17
+
18
+ interface TaskEventDetails {
19
+ readonly taskId: string;
20
+ readonly eventId: string;
21
+ }
22
+
23
+ /** Inserts durable Pi evidence before allowing the relay mailbox cursor to advance. */
24
+ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<void> {
25
+ if (context.hasPendingMessages()) return;
26
+ const deliveries = await core.receive(signal);
27
+ for (const delivery of deliveries) {
28
+ if (delivery.envelope.kind === TaskEnvelopeKind.intent) {
29
+ await core.acknowledgeRelayDelivery(delivery.cursor, signal);
30
+ pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
31
+ continue;
32
+ }
33
+ const event = inboxEvent(delivery);
34
+ if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
35
+ if (context.hasPendingMessages()) return;
36
+ const eventKey = key(event.taskId, event.eventId);
37
+ if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey) && isModelVisible(event.type)) {
38
+ pi.sendMessage({
39
+ customType: TASK_EVENT_CUSTOM_TYPE,
40
+ content: renderTaskEvent(event),
41
+ display: true,
42
+ details: { taskId: event.taskId, eventId: event.eventId },
43
+ }, { triggerTurn: true, deliverAs: "followUp" });
44
+ if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
45
+ }
46
+ if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
47
+ await core.acknowledgeRelayDelivery(delivery.cursor, signal);
48
+ pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
49
+ }
50
+ }
51
+
52
+ export function restoredInboxCursor(entries: readonly unknown[]): string {
53
+ let cursor = "0";
54
+ for (const entry of entries) {
55
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== TASK_CURSOR_CUSTOM_TYPE || !isRecord(entry.data) || !decimal(entry.data.cursor)) continue;
56
+ cursor = entry.data.cursor;
57
+ }
58
+ return cursor;
59
+ }
60
+
61
+ function inboxEvent(delivery: RelayDelivery): TaskEvent {
62
+ try {
63
+ const payload = JSON.parse(delivery.envelope.payload) as unknown;
64
+ if (!isRecord(payload)) throw new Error("not an object");
65
+ if (delivery.envelope.kind === TaskEnvelopeKind.assignment && isTaskEvent(payload.event)) return payload.event;
66
+ if (delivery.envelope.kind === TaskEnvelopeKind.canonicalEvent && isTaskEvent(payload)) return payload;
67
+ } catch { /* normalize malformed opaque payloads to a protocol error */ }
68
+ throw new TaskProtocolError("INVALID_INBOX_EVENT", "relay envelope does not contain a valid model event");
69
+ }
70
+
71
+ function isKnownEvent(type: string): boolean {
72
+ return ["task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out", "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
73
+ }
74
+
75
+ function isModelVisible(type: string): boolean {
76
+ return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
77
+ }
78
+
79
+ function renderTaskEvent(event: TaskEvent): string {
80
+ if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
81
+ const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
82
+ const summary = typeof event.payload.summary === "string" ? `\n\n**summary:** ${event.payload.summary}` : "";
83
+ return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
84
+ }
85
+
86
+ function incorporatedEvents(entries: readonly unknown[]): Set<string> {
87
+ const events = new Set<string>();
88
+ for (const entry of entries) {
89
+ if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== TASK_EVENT_CUSTOM_TYPE || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
90
+ events.add(key(entry.details.taskId, entry.details.eventId));
91
+ }
92
+ return events;
93
+ }
94
+
95
+ function isTaskEvent(value: unknown): value is TaskEvent {
96
+ return isRecord(value) && typeof value.eventId === "string" && typeof value.taskId === "string" && typeof value.type === "string" && typeof value.sequence === "string" && isRecord(value.source) && isRecord(value.target) && typeof value.occurredAt === "number" && isRecord(value.payload);
97
+ }
98
+
99
+ function key(taskId: string, eventId: string): string {
100
+ return `${taskId}\u0000${eventId}`;
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return value !== null && typeof value === "object" && !Array.isArray(value);
105
+ }
106
+
107
+ function decimal(value: unknown): value is string {
108
+ return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
109
+ }
@@ -0,0 +1,129 @@
1
+ export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
2
+ export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
3
+
4
+ export interface TaskEndpoint {
5
+ readonly relay: string;
6
+ readonly id: string;
7
+ }
8
+
9
+ /** A provider-defined reference which core resolves without interpreting. */
10
+ export interface RelayTargetReference {
11
+ readonly relay: string;
12
+ readonly reference: string;
13
+ }
14
+
15
+ export interface RelayConnectInput {
16
+ readonly endpoint: TaskEndpoint;
17
+ readonly protocolVersion: string;
18
+ readonly receiveCursor: string;
19
+ }
20
+
21
+ export interface RelayConnection {
22
+ readonly endpoint: TaskEndpoint;
23
+ readonly receiveCursor: string;
24
+ }
25
+
26
+ export const TaskEnvelopeKind = {
27
+ assignment: "assignment",
28
+ intent: "intent",
29
+ canonicalEvent: "canonical_event",
30
+ } as const;
31
+
32
+ export type TaskEnvelopeKind = (typeof TaskEnvelopeKind)[keyof typeof TaskEnvelopeKind];
33
+
34
+ export interface RelayEnvelope {
35
+ readonly envelopeId: string;
36
+ readonly protocolVersion: string;
37
+ readonly source: TaskEndpoint;
38
+ readonly target: TaskEndpoint;
39
+ readonly taskId: string;
40
+ readonly kind: TaskEnvelopeKind;
41
+ /** Opaque to relays. The endpoint protocol defines its JSON representation. */
42
+ readonly payload: string;
43
+ }
44
+
45
+ export interface RelayAcceptance {
46
+ readonly envelopeId: string;
47
+ }
48
+
49
+ export interface RelayReceiveRequest {
50
+ readonly endpoint: TaskEndpoint;
51
+ readonly cursor: string;
52
+ readonly limit: number;
53
+ }
54
+
55
+ export interface RelayDelivery {
56
+ readonly cursor: string;
57
+ readonly envelope: RelayEnvelope;
58
+ }
59
+
60
+ export interface RelayInboxPage {
61
+ readonly deliveries: readonly RelayDelivery[];
62
+ readonly nextCursor: string;
63
+ readonly hasMore: boolean;
64
+ }
65
+
66
+ export interface RelayDeliveryAck {
67
+ readonly endpoint: TaskEndpoint;
68
+ readonly cursor: string;
69
+ }
70
+
71
+ export interface TaskRelay {
72
+ readonly id: string;
73
+ connect(input: RelayConnectInput, signal?: AbortSignal): Promise<RelayConnection>;
74
+ resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
75
+ send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
76
+ receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
77
+ acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
78
+ }
79
+
80
+ export interface TaskEvent {
81
+ readonly eventId: string;
82
+ readonly taskId: string;
83
+ readonly type: string;
84
+ readonly sequence: string;
85
+ readonly source: TaskEndpoint;
86
+ readonly target: TaskEndpoint;
87
+ readonly occurredAt: number;
88
+ readonly payload: Record<string, unknown>;
89
+ }
90
+
91
+ export interface TaskRecord {
92
+ readonly taskId: string;
93
+ readonly protocolVersion: string;
94
+ readonly origin: TaskEndpoint;
95
+ readonly target: TaskEndpoint;
96
+ readonly task: string;
97
+ readonly createdAt: number;
98
+ readonly expiresAt: number;
99
+ readonly status: "active" | "completed" | "failed" | "cancelled" | "timed_out";
100
+ readonly events: readonly TaskEvent[];
101
+ }
102
+
103
+ export interface TaskIntent {
104
+ readonly intentId: string;
105
+ readonly taskId: string;
106
+ readonly type: "task.completed" | "task.failed" | "task.cancelled" | "task.information" | "task.question" | "task.answer" | "task.delivery_receipt";
107
+ readonly payload: Record<string, unknown>;
108
+ }
109
+
110
+ export class TaskProtocolError extends Error {
111
+ readonly code: string;
112
+ readonly retryable: boolean;
113
+ readonly details: Readonly<Record<string, unknown>> | undefined;
114
+
115
+ constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
116
+ super(message);
117
+ this.name = "TaskProtocolError";
118
+ this.code = code;
119
+ this.retryable = options.retryable ?? true;
120
+ this.details = options.details;
121
+ }
122
+ }
123
+
124
+ export class TaskOutboxDeliveryError extends TaskProtocolError {
125
+ constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
126
+ super(code, message, options);
127
+ this.name = "TaskOutboxDeliveryError";
128
+ }
129
+ }
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import { TASK_PROTOCOL_VERSION } from "./task-protocol";
4
+ import type { TaskRelay } from "./task-protocol";
5
+
6
+ /** Shared assertions external relay adapters can run against their implementation. */
7
+ export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
8
+ test("enforces registration, accepts duplicate envelopes once, and retains delivery until acknowledgement", async () => {
9
+ const relay = createRelay();
10
+ const source = { relay: relay.id, id: "source" };
11
+ const target = { relay: relay.id, id: "target" };
12
+ await relay.connect({ endpoint: source, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
13
+ await relay.connect({ endpoint: target, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
14
+ await expect(relay.resolve({ relay: relay.id, reference: "missing" })).rejects.toThrow("not registered");
15
+ const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}" };
16
+ await relay.send(envelope);
17
+ await relay.send(envelope);
18
+ const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
19
+ expect(page.deliveries).toHaveLength(1);
20
+ await relay.acknowledgeDelivery({ endpoint: target, cursor: page.nextCursor });
21
+ expect((await relay.receive({ endpoint: target, cursor: page.nextCursor, limit: 10 })).deliveries).toHaveLength(0);
22
+ });
23
+ }