@sgtbeatdown/pi-tasks 0.1.1 → 0.1.3
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.md +80 -9
- package/package.json +6 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +51 -27
- package/src/extension.ts +236 -137
- package/src/gateway-client.ts +32 -8
- package/src/in-memory-task-relay.ts +114 -0
- package/src/index.ts +35 -0
- package/src/legacy-extension.ts +248 -0
- package/src/legacy-task-inbox.ts +148 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-core.ts +430 -0
- package/src/task-inbox.ts +68 -90
- package/src/task-protocol.ts +164 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +491 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +343 -0
package/src/task-core.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_RELAY_PAYLOAD_BYTES,
|
|
3
|
+
PARENT_ACKNOWLEDGMENT_OPERATION,
|
|
4
|
+
TASK_PROTOCOL_VERSION,
|
|
5
|
+
TERMINAL_INTENT_OPERATION,
|
|
6
|
+
TaskEnvelopeKind,
|
|
7
|
+
TaskOutboxDeliveryError,
|
|
8
|
+
TaskProtocolError,
|
|
9
|
+
} from "./task-protocol";
|
|
10
|
+
import type {
|
|
11
|
+
RelayDelivery,
|
|
12
|
+
RelayEnvelope,
|
|
13
|
+
TaskEndpoint,
|
|
14
|
+
TaskEvent,
|
|
15
|
+
TaskIntent,
|
|
16
|
+
TaskRecord,
|
|
17
|
+
TaskRelay,
|
|
18
|
+
TaskSnapshot,
|
|
19
|
+
} from "./task-protocol";
|
|
20
|
+
import type { TaskStore } from "./task-store";
|
|
21
|
+
|
|
22
|
+
const RECEIVE_PAGE_SIZE = 100;
|
|
23
|
+
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
24
|
+
const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
|
|
25
|
+
const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
|
|
26
|
+
const CANONICAL_EVENTS = new Set([
|
|
27
|
+
"task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
|
|
28
|
+
"task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
|
|
29
|
+
]);
|
|
30
|
+
const RECEIVER_INTENT_TYPES = new Set<TaskIntent["type"]>([
|
|
31
|
+
"task.completed", "task.failed", "task.cancelled", "task.information", "task.question", "task.answer", "task.delivery_receipt",
|
|
32
|
+
]);
|
|
33
|
+
const IGNORE_OUTBOX_FAILURES: ReadonlySet<string> = new Set();
|
|
34
|
+
|
|
35
|
+
export interface TaskCoreOptions {
|
|
36
|
+
readonly endpoint: TaskEndpoint;
|
|
37
|
+
readonly relay: TaskRelay;
|
|
38
|
+
readonly store: TaskStore;
|
|
39
|
+
readonly clock?: { readonly now: () => number };
|
|
40
|
+
readonly ids?: () => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CreateTaskInput {
|
|
44
|
+
readonly target: TaskEndpoint;
|
|
45
|
+
readonly task: string;
|
|
46
|
+
readonly timeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SubmitIntentInput {
|
|
50
|
+
readonly taskId: string;
|
|
51
|
+
readonly type: TaskIntent["type"];
|
|
52
|
+
readonly payload: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TaskCore {
|
|
56
|
+
readonly endpoint: TaskEndpoint;
|
|
57
|
+
connect(signal?: AbortSignal): Promise<void>;
|
|
58
|
+
createTask(input: CreateTaskInput, signal?: AbortSignal): Promise<{ readonly taskId: string }>;
|
|
59
|
+
getTask(taskId: string): TaskSnapshot | undefined;
|
|
60
|
+
listTasks(): readonly TaskSnapshot[];
|
|
61
|
+
flushOutbox(signal?: AbortSignal): Promise<void>;
|
|
62
|
+
receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
|
|
63
|
+
acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
|
|
64
|
+
submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
|
|
65
|
+
recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
|
|
66
|
+
evaluateTimeouts(signal?: AbortSignal): Promise<void>;
|
|
67
|
+
acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ReceivedEnvelope =
|
|
71
|
+
| { readonly kind: "assignment"; readonly task: Omit<TaskRecord, "events">; readonly event: TaskEvent }
|
|
72
|
+
| { readonly kind: "intent"; readonly task: TaskRecord; readonly intent: TaskIntent }
|
|
73
|
+
| { readonly kind: "canonical_event"; readonly task: TaskRecord; readonly event: TaskEvent };
|
|
74
|
+
|
|
75
|
+
export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
76
|
+
const clock = options.clock ?? { now: (): number => Date.now() };
|
|
77
|
+
const ids = options.ids ?? (() => crypto.randomUUID());
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
endpoint: options.endpoint,
|
|
81
|
+
async connect(signal) {
|
|
82
|
+
const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
|
|
83
|
+
if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
},
|
|
86
|
+
async createTask(input, signal) {
|
|
87
|
+
if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
|
|
88
|
+
const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
|
|
89
|
+
if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
|
|
90
|
+
const now = clock.now();
|
|
91
|
+
const taskId = ids();
|
|
92
|
+
const task: Omit<TaskRecord, "events"> = {
|
|
93
|
+
taskId, protocolVersion: TASK_PROTOCOL_VERSION, origin: options.endpoint, target, task: input.task,
|
|
94
|
+
createdAt: now, expiresAt: now + input.timeoutMs, status: "active",
|
|
95
|
+
};
|
|
96
|
+
const created = event(task, ids(), "task.created", "1", options.endpoint, target, now, { task: input.task });
|
|
97
|
+
const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created });
|
|
98
|
+
options.store.transaction(() => {
|
|
99
|
+
options.store.putTask(task);
|
|
100
|
+
options.store.appendEvent(created);
|
|
101
|
+
options.store.putOutbox(assignment);
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
await flush(options, signal, new Set([assignment.envelopeId]));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw operationError(error, taskId);
|
|
107
|
+
}
|
|
108
|
+
return { taskId };
|
|
109
|
+
},
|
|
110
|
+
getTask(taskId) { return options.store.getTask(taskId); },
|
|
111
|
+
listTasks() { return options.store.listTasks(); },
|
|
112
|
+
async flushOutbox(signal) { await flush(options, signal); },
|
|
113
|
+
async receive(signal) {
|
|
114
|
+
await flush(options, signal, IGNORE_OUTBOX_FAILURES);
|
|
115
|
+
const page = await options.relay.receive({ endpoint: options.endpoint, cursor: options.store.getReceiveCursor(), limit: RECEIVE_PAGE_SIZE }, signal);
|
|
116
|
+
if (!isInboxPage(page)) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
|
|
117
|
+
const visibleDeliveries: RelayDelivery[] = [];
|
|
118
|
+
for (const delivery of page.deliveries) {
|
|
119
|
+
if (!isDelivery(delivery)) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid delivery");
|
|
120
|
+
const received = validateReceivedEnvelope(options, delivery.envelope);
|
|
121
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
122
|
+
options.store.transaction(() => {
|
|
123
|
+
if (!options.store.persistInbox(delivery.envelope, delivery.cursor)) return;
|
|
124
|
+
persistedEnvelopeIds = persistReceivedEnvelope(options, clock.now, ids, received);
|
|
125
|
+
});
|
|
126
|
+
if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
127
|
+
if (received.kind === "intent") {
|
|
128
|
+
await this.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
visibleDeliveries.push(delivery);
|
|
132
|
+
}
|
|
133
|
+
return visibleDeliveries;
|
|
134
|
+
},
|
|
135
|
+
async acknowledgeRelayDelivery(cursor, signal) {
|
|
136
|
+
if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
137
|
+
await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
|
|
138
|
+
options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
|
|
139
|
+
},
|
|
140
|
+
async submitIntent(input, signal) {
|
|
141
|
+
if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
|
|
142
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
143
|
+
let receiverTerminal = false;
|
|
144
|
+
options.store.transaction(() => {
|
|
145
|
+
const task = requiredTask(options.store, input.taskId);
|
|
146
|
+
receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
|
|
147
|
+
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
|
|
148
|
+
});
|
|
149
|
+
await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
150
|
+
if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
|
|
151
|
+
},
|
|
152
|
+
async recordInsertion(input, signal) {
|
|
153
|
+
const task = requiredTask(options.store, input.taskId);
|
|
154
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
155
|
+
options.store.transaction(() => {
|
|
156
|
+
if (options.store.putInsertionReceipt(input.taskId, input.eventId)) {
|
|
157
|
+
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, { taskId: input.taskId, type: "task.delivery_receipt", payload: { eventId: input.eventId } });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
161
|
+
},
|
|
162
|
+
async evaluateTimeouts(signal) {
|
|
163
|
+
const persistedEnvelopeIds: string[] = [];
|
|
164
|
+
for (const candidate of options.store.listTasks()) {
|
|
165
|
+
if (!sameEndpoint(candidate.origin, options.endpoint) || TERMINAL_STATUSES.has(candidate.status) || candidate.expiresAt > clock.now()) continue;
|
|
166
|
+
options.store.transaction(() => {
|
|
167
|
+
const task = options.store.getTask(candidate.taskId);
|
|
168
|
+
if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
|
|
169
|
+
persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out"));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
173
|
+
},
|
|
174
|
+
async acknowledgeParent(taskId, signal) {
|
|
175
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
176
|
+
options.store.transaction(() => {
|
|
177
|
+
const task = requiredTask(options.store, taskId);
|
|
178
|
+
if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
|
|
179
|
+
if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
|
|
180
|
+
persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION);
|
|
181
|
+
});
|
|
182
|
+
await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateReceivedEnvelope(options: TaskCoreOptions, relayEnvelope: unknown): ReceivedEnvelope {
|
|
188
|
+
if (!isEnvelope(relayEnvelope)) throw new TaskProtocolError("INVALID_ENVELOPE", "relay envelope is malformed");
|
|
189
|
+
if (relayEnvelope.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "received an incompatible task envelope");
|
|
190
|
+
if (!sameEndpoint(relayEnvelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "relay envelope targets another endpoint");
|
|
191
|
+
const payload = parsePayload(relayEnvelope);
|
|
192
|
+
if (relayEnvelope.kind === TaskEnvelopeKind.assignment) return validateAssignment(relayEnvelope, payload);
|
|
193
|
+
if (relayEnvelope.kind === TaskEnvelopeKind.intent) return validateIntent(options, relayEnvelope, payload);
|
|
194
|
+
return validateCanonicalEvent(options, relayEnvelope, payload);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateAssignment(envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
198
|
+
if (!isRecord(payload) || !isTaskRecordInput(payload.task) || !isTaskEvent(payload.event)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment payload is malformed");
|
|
199
|
+
const task = payload.task;
|
|
200
|
+
const created = payload.event;
|
|
201
|
+
if (envelope.taskId !== task.taskId || envelope.taskId !== created.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
|
|
202
|
+
|| task.status !== "active" || task.expiresAt < task.createdAt || created.type !== "task.created" || created.sequence !== "1" || created.payload.task !== task.task
|
|
203
|
+
|| !sameEndpoint(envelope.source, task.origin) || !sameEndpoint(envelope.target, task.target)
|
|
204
|
+
|| !sameEndpoint(created.source, task.origin) || !sameEndpoint(created.target, task.target)) {
|
|
205
|
+
throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment envelope headers do not match its payload");
|
|
206
|
+
}
|
|
207
|
+
return { kind: "assignment", task, event: created };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateIntent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
211
|
+
const task = requiredTask(options.store, envelope.taskId);
|
|
212
|
+
if (!sameEndpoint(task.origin, options.endpoint) || !sameEndpoint(envelope.source, task.target) || !sameEndpoint(envelope.target, task.origin)) {
|
|
213
|
+
throw new TaskProtocolError("UNAUTHORIZED_INTENT", "only the assigned receiver may submit an intent to origin");
|
|
214
|
+
}
|
|
215
|
+
if (!isTaskIntent(payload) || payload.taskId !== envelope.taskId || !RECEIVER_INTENT_TYPES.has(payload.type)) {
|
|
216
|
+
throw new TaskProtocolError("INVALID_INTENT", "intent envelope headers or payload are invalid");
|
|
217
|
+
}
|
|
218
|
+
return { kind: "intent", task, intent: payload };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
222
|
+
const task = requiredTask(options.store, envelope.taskId);
|
|
223
|
+
if (!isTaskEvent(payload) || !CANONICAL_EVENTS.has(payload.type) || payload.taskId !== envelope.taskId
|
|
224
|
+
|| !sameEndpoint(envelope.source, task.origin) || (!sameEndpoint(envelope.target, task.target) && !sameEndpoint(envelope.target, task.origin))
|
|
225
|
+
|| !sameEndpoint(payload.source, task.origin) || !sameEndpoint(payload.target, task.target)) {
|
|
226
|
+
throw new TaskProtocolError("INVALID_EVENT", "canonical event envelope headers or payload are invalid");
|
|
227
|
+
}
|
|
228
|
+
if (!sameEndpoint(envelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "canonical event targets another endpoint");
|
|
229
|
+
return { kind: "canonical_event", task, event: payload };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope): readonly string[] {
|
|
233
|
+
if (received.kind === "assignment") {
|
|
234
|
+
options.store.putTask(received.task);
|
|
235
|
+
options.store.appendEvent(received.event);
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type);
|
|
239
|
+
if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput): readonly string[] {
|
|
244
|
+
if (sameEndpoint(options.endpoint, task.origin)) {
|
|
245
|
+
return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type);
|
|
246
|
+
}
|
|
247
|
+
const envelopeId = ids();
|
|
248
|
+
const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
|
|
249
|
+
if (TERMINAL_EVENTS.has(input.type)) {
|
|
250
|
+
const reservation = options.store.reserveTaskOperation({
|
|
251
|
+
taskId: input.taskId,
|
|
252
|
+
operation: TERMINAL_INTENT_OPERATION,
|
|
253
|
+
logicalId: intent.intentId,
|
|
254
|
+
logicalType: intent.type,
|
|
255
|
+
envelopeIds: [envelopeId],
|
|
256
|
+
});
|
|
257
|
+
if (!reservation.created) {
|
|
258
|
+
if (reservation.record.logicalType !== intent.type) {
|
|
259
|
+
throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
|
|
260
|
+
retryable: false,
|
|
261
|
+
details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return reservation.record.envelopeIds;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
options.store.putIntent(intent.intentId, input.taskId, envelopeId);
|
|
268
|
+
options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
|
|
269
|
+
return [envelopeId];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): readonly string[] {
|
|
273
|
+
const terminal = TERMINAL_EVENTS.has(requestedType);
|
|
274
|
+
const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
|
|
275
|
+
const sequence = String(task.events.length + 1);
|
|
276
|
+
const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
|
|
277
|
+
const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
|
|
278
|
+
const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
|
|
279
|
+
const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
|
|
280
|
+
if (operation !== undefined) {
|
|
281
|
+
const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
|
|
282
|
+
if (!reservation.created) return reservation.record.envelopeIds;
|
|
283
|
+
}
|
|
284
|
+
options.store.appendEvent(canonical);
|
|
285
|
+
if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
|
|
286
|
+
options.store.putOutbox(targetEnvelope);
|
|
287
|
+
if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
|
|
288
|
+
return persistedEnvelopeIds;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
|
|
292
|
+
const failures: Array<{ readonly envelopeId: string; readonly error: unknown }> = [];
|
|
293
|
+
for (const record of options.store.outbox("pending")) {
|
|
294
|
+
try {
|
|
295
|
+
await options.relay.send(record.envelope, signal);
|
|
296
|
+
options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
|
|
299
|
+
if (error instanceof TaskProtocolError && error.code === TARGET_NOT_REGISTERED_CODE && !error.retryable) {
|
|
300
|
+
options.store.transaction(() => {
|
|
301
|
+
options.store.quarantineOutbox(record.envelope.envelopeId, {
|
|
302
|
+
errorCode: error.code,
|
|
303
|
+
reason: error.message,
|
|
304
|
+
details: error.details ?? {},
|
|
305
|
+
quarantinedAt: Date.now(),
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
failures.push({ envelopeId: record.envelope.envelopeId, error });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const reportedFailure = failures.find((failure) => reportedEnvelopeIds === undefined || reportedEnvelopeIds.has(failure.envelopeId));
|
|
313
|
+
if (reportedFailure) throw outboxDeliveryError(reportedFailure.error);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function outboxDeliveryError(error: unknown): TaskOutboxDeliveryError {
|
|
317
|
+
if (error instanceof TaskProtocolError) {
|
|
318
|
+
return new TaskOutboxDeliveryError(error.code, error.message, { retryable: error.retryable, details: error.details });
|
|
319
|
+
}
|
|
320
|
+
return new TaskOutboxDeliveryError("TASK_ERROR", error instanceof Error ? error.message : "task delivery failed");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function operationError(error: unknown, taskId: string): TaskProtocolError {
|
|
324
|
+
if (error instanceof TaskProtocolError) {
|
|
325
|
+
return new TaskProtocolError(error.code, error.message, { retryable: error.retryable, details: { ...error.details, taskId } });
|
|
326
|
+
}
|
|
327
|
+
return new TaskProtocolError("TASK_ERROR", error instanceof Error ? error.message : "task operation failed", { details: { taskId } });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function event(task: Omit<TaskRecord, "events">, eventId: string, type: string, sequence: string, source: TaskEndpoint, target: TaskEndpoint, occurredAt: number, payload: Record<string, unknown>): TaskEvent {
|
|
331
|
+
return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown): RelayEnvelope {
|
|
335
|
+
return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parsePayload(envelope: RelayEnvelope): unknown {
|
|
339
|
+
try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
|
|
343
|
+
const delivery = requiredTask(store, taskId).terminalDelivery;
|
|
344
|
+
if (delivery.state !== "delivery_blocked") return;
|
|
345
|
+
throw new TaskOutboxDeliveryError(delivery.error.code, "terminal task delivery is blocked", {
|
|
346
|
+
retryable: false,
|
|
347
|
+
details: { ...delivery.error.details, taskId, envelopeId: delivery.envelopeId, origin: delivery.origin, blockedAt: delivery.blockedAt },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
|
|
352
|
+
const task = store.getTask(taskId);
|
|
353
|
+
if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`);
|
|
354
|
+
return task;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function statusFor(type: string): TaskRecord["status"] {
|
|
358
|
+
if (type === "task.completed") return "completed";
|
|
359
|
+
if (type === "task.failed") return "failed";
|
|
360
|
+
if (type === "task.cancelled") return "cancelled";
|
|
361
|
+
return "timed_out";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isInboxPage(value: unknown): value is { readonly deliveries: readonly RelayDelivery[]; readonly nextCursor: string; readonly hasMore: boolean } {
|
|
365
|
+
return isRecord(value) && Array.isArray(value.deliveries) && decimal(value.nextCursor) && typeof value.hasMore === "boolean";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isDelivery(value: unknown): value is RelayDelivery {
|
|
369
|
+
return isRecord(value) && decimal(value.cursor) && isEnvelope(value.envelope);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isEnvelope(value: unknown): value is RelayEnvelope {
|
|
373
|
+
return isRecord(value) && nonEmpty(value.envelopeId) && value.protocolVersion === TASK_PROTOCOL_VERSION && nonEmpty(value.taskId)
|
|
374
|
+
&& isEndpoint(value.source) && isEndpoint(value.target) && isEnvelopeKind(value.kind)
|
|
375
|
+
&& typeof value.payload === "string" && new TextEncoder().encode(value.payload).byteLength <= MAX_RELAY_PAYLOAD_BYTES;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isTaskRecordInput(value: unknown): value is Omit<TaskRecord, "events"> {
|
|
379
|
+
return isRecord(value) && nonEmpty(value.taskId) && value.protocolVersion === TASK_PROTOCOL_VERSION && isEndpoint(value.origin) && isEndpoint(value.target)
|
|
380
|
+
&& nonEmpty(value.task) && finiteNumber(value.createdAt) && finiteNumber(value.expiresAt) && isTaskStatus(value.status);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isTaskEvent(value: unknown): value is TaskEvent {
|
|
384
|
+
return isRecord(value) && nonEmpty(value.eventId) && nonEmpty(value.taskId) && nonEmpty(value.type) && positiveDecimal(value.sequence)
|
|
385
|
+
&& isEndpoint(value.source) && isEndpoint(value.target) && finiteNumber(value.occurredAt) && isRecord(value.payload);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isTaskIntent(value: unknown): value is TaskIntent {
|
|
389
|
+
return isRecord(value) && nonEmpty(value.intentId) && nonEmpty(value.taskId) && typeof value.type === "string" && isRecord(value.payload);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isEndpoint(value: unknown): value is TaskEndpoint {
|
|
393
|
+
return isRecord(value) && nonEmpty(value.relay) && nonEmpty(value.id);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isEnvelopeKind(value: unknown): value is RelayEnvelope["kind"] {
|
|
397
|
+
return value === TaskEnvelopeKind.assignment || value === TaskEnvelopeKind.intent || value === TaskEnvelopeKind.canonicalEvent;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isTaskStatus(value: unknown): value is TaskRecord["status"] {
|
|
401
|
+
return value === "active" || value === "completed" || value === "failed" || value === "cancelled" || value === "timed_out";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
|
|
405
|
+
return left.relay === right.relay && left.id === right.id;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
409
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function nonEmpty(value: unknown): value is string {
|
|
413
|
+
return typeof value === "string" && value.length > 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function finiteNumber(value: unknown): value is number {
|
|
417
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function decimal(value: unknown): value is string {
|
|
421
|
+
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function positiveDecimal(value: unknown): value is string {
|
|
425
|
+
return typeof value === "string" && /^[1-9][0-9]*$/.test(value);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
429
|
+
if (signal?.aborted) throw new TaskProtocolError("ABORTED", "task relay connection was cancelled");
|
|
430
|
+
}
|
package/src/task-inbox.ts
CHANGED
|
@@ -1,78 +1,60 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TaskCore } from "./task-core";
|
|
2
|
+
import { TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
|
|
3
|
+
import type { RelayDelivery, TaskEvent } from "./task-protocol";
|
|
2
4
|
|
|
3
|
-
const TASK_EVENT_CUSTOM_TYPE = "
|
|
4
|
-
const TASK_CURSOR_CUSTOM_TYPE = "
|
|
5
|
+
const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
|
|
6
|
+
const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
|
|
5
7
|
|
|
6
8
|
interface InboxContext {
|
|
7
|
-
readonly isIdle: () => boolean;
|
|
8
9
|
readonly hasPendingMessages: () => boolean;
|
|
9
|
-
readonly sessionManager: { readonly
|
|
10
|
+
readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface InboxPi {
|
|
13
|
-
sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true }): void;
|
|
14
|
+
sendMessage(message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails }, options: { readonly triggerTurn: true; readonly deliverAs: "followUp" }): void;
|
|
14
15
|
appendEntry(customType: string, data: { readonly cursor: string }): void;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
interface TaskEventDetails {
|
|
18
|
+
export interface TaskEventDetails {
|
|
18
19
|
readonly taskId: string;
|
|
19
20
|
readonly eventId: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
23
|
+
/** Inserts durable Pi evidence before allowing the relay mailbox cursor to advance. */
|
|
24
|
+
export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, pendingInsertions: Set<string>, 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
|
+
const incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
38
|
+
if (incorporated) pendingInsertions.delete(eventKey);
|
|
39
|
+
if (!incorporated && isModelVisible(event.type)) {
|
|
40
|
+
if (!pendingInsertions.has(eventKey)) {
|
|
41
|
+
pi.sendMessage({
|
|
42
|
+
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
43
|
+
content: renderTaskEvent(event),
|
|
44
|
+
display: true,
|
|
45
|
+
details: { taskId: event.taskId, eventId: event.eventId },
|
|
46
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
47
|
+
pendingInsertions.add(eventKey);
|
|
45
48
|
}
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
49
|
-
content: renderTaskEvent(event, snapshot),
|
|
50
|
-
display: true,
|
|
51
|
-
details: { taskId: event.taskId, eventId: event.id },
|
|
52
|
-
}, { triggerTurn: true });
|
|
53
|
-
if (!incorporatedEvents(ctx.sessionManager.buildContextEntries()).has(eventKey)) return cursor;
|
|
54
|
-
incorporated.add(eventKey);
|
|
55
|
-
if (event.destination.sessionId === snapshot.task.target.sessionId) await client.delivered(event.taskId, event.id);
|
|
49
|
+
if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
|
|
50
|
+
pendingInsertions.delete(eventKey);
|
|
56
51
|
}
|
|
57
|
-
|
|
58
|
-
cursor
|
|
59
|
-
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor });
|
|
60
|
-
if (!page.hasMore) return cursor;
|
|
52
|
+
if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
|
|
53
|
+
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
54
|
+
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
|
|
61
55
|
}
|
|
62
56
|
}
|
|
63
57
|
|
|
64
|
-
function hasPersistedCursor(entries: readonly unknown[]): boolean {
|
|
65
|
-
return entries.some((entry) => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_CURSOR_CUSTOM_TYPE && isRecord(entry.data) && decimal(entry.data.cursor));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function modelRelevant(event: TaskEvent): boolean {
|
|
69
|
-
return event.type === "task.created" || event.type === "task.question" || event.type === "task.answer" || event.type === "task.information" || terminalEvent(event.type) || event.type === "task.cancel_requested";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function terminalHistoryAcknowledged(snapshot: TaskStatus): boolean {
|
|
73
|
-
return ["completed", "failed", "cancelled", "timed_out"].includes(snapshot.status) && snapshot.events.some((event) => event.type === "task.parent_acknowledged");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
58
|
export function restoredInboxCursor(entries: readonly unknown[]): string {
|
|
77
59
|
let cursor = "0";
|
|
78
60
|
for (const entry of entries) {
|
|
@@ -82,46 +64,46 @@ export function restoredInboxCursor(entries: readonly unknown[]): string {
|
|
|
82
64
|
return cursor;
|
|
83
65
|
}
|
|
84
66
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (!isRecord(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
67
|
+
function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
|
68
|
+
try {
|
|
69
|
+
const payload = JSON.parse(delivery.envelope.payload) as unknown;
|
|
70
|
+
if (!isRecord(payload)) throw new Error("not an object");
|
|
71
|
+
if (delivery.envelope.kind === TaskEnvelopeKind.assignment && isTaskEvent(payload.event)) return payload.event;
|
|
72
|
+
if (delivery.envelope.kind === TaskEnvelopeKind.canonicalEvent && isTaskEvent(payload)) return payload;
|
|
73
|
+
} catch { /* normalize malformed opaque payloads to a protocol error */ }
|
|
74
|
+
throw new TaskProtocolError("INVALID_INBOX_EVENT", "relay envelope does not contain a valid model event");
|
|
92
75
|
}
|
|
93
76
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (event.type === "task.created") return renderAssignment(header, snapshot.task, snapshot.warnings);
|
|
97
|
-
const message = event.message ? `\n\n${event.message}` : "";
|
|
98
|
-
const completion = event.completion ?? snapshot.completion;
|
|
99
|
-
const completionText = completion ? `\n\n**summary:** ${completion.summary}` : "";
|
|
100
|
-
const parentPrompt = terminalEvent(event.type) && snapshot.task.onCompletePrompt
|
|
101
|
-
? `\n\n## parent follow-up\n${snapshot.task.onCompletePrompt}`
|
|
102
|
-
: "";
|
|
103
|
-
const warnings = renderWarnings(snapshot.warnings);
|
|
104
|
-
return `## task ${label(event.type)}\n${header}${message}${completionText}${parentPrompt}${warnings}`;
|
|
77
|
+
function isKnownEvent(type: string): boolean {
|
|
78
|
+
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);
|
|
105
79
|
}
|
|
106
80
|
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
const refs = assignment.context?.refs?.length
|
|
110
|
-
? `\n\n## selected refs\n${assignment.context.refs.map((ref) => `- \`${ref.path}\`${ref.selector ? ` — ${ref.selector}` : ""}${ref.purpose ? ` — ${ref.purpose}` : ""}`).join("\n")}`
|
|
111
|
-
: "";
|
|
112
|
-
return `## task assignment\n${header}\n\n${assignment.task}${context}${refs}${renderWarnings(warnings)}`;
|
|
81
|
+
function isModelVisible(type: string): boolean {
|
|
82
|
+
return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
|
|
113
83
|
}
|
|
114
84
|
|
|
115
|
-
function
|
|
116
|
-
|
|
85
|
+
function renderTaskEvent(event: TaskEvent): string {
|
|
86
|
+
if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
|
|
87
|
+
const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";
|
|
88
|
+
const summary = typeof event.payload.summary === "string" ? `\n\n**summary:** ${event.payload.summary}` : "";
|
|
89
|
+
return `## task ${event.type.replace(/^task\./, "").replaceAll("_", " ")}\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`${body}${summary}`;
|
|
117
90
|
}
|
|
118
91
|
|
|
119
|
-
function
|
|
120
|
-
|
|
92
|
+
export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
|
|
93
|
+
const events: TaskEventDetails[] = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
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;
|
|
96
|
+
events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
|
|
97
|
+
}
|
|
98
|
+
return events;
|
|
121
99
|
}
|
|
122
100
|
|
|
123
|
-
function
|
|
124
|
-
return
|
|
101
|
+
function incorporatedEvents(entries: readonly unknown[]): Set<string> {
|
|
102
|
+
return new Set(incorporatedTaskEvents(entries).map((event) => key(event.taskId, event.eventId)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isTaskEvent(value: unknown): value is TaskEvent {
|
|
106
|
+
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);
|
|
125
107
|
}
|
|
126
108
|
|
|
127
109
|
function key(taskId: string, eventId: string): string {
|
|
@@ -132,10 +114,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
132
114
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
133
115
|
}
|
|
134
116
|
|
|
135
|
-
function nonEmpty(value: unknown): value is string {
|
|
136
|
-
return typeof value === "string" && value.length > 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
117
|
function decimal(value: unknown): value is string {
|
|
140
118
|
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
141
119
|
}
|