@sgtbeatdown/pi-tasks 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -9
- package/package.json +6 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +19 -10
- package/src/extension.ts +181 -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 +383 -0
- package/src/task-inbox.ts +55 -87
- package/src/task-protocol.ts +129 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +238 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +319 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
export interface SqliteRunResult {
|
|
4
|
+
readonly changes: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SqliteStatement {
|
|
8
|
+
run(...parameters: readonly unknown[]): SqliteRunResult;
|
|
9
|
+
get(...parameters: readonly unknown[]): unknown;
|
|
10
|
+
all(...parameters: readonly unknown[]): readonly unknown[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SqliteDatabase {
|
|
14
|
+
exec(sql: string): void;
|
|
15
|
+
query(sql: string): SqliteStatement;
|
|
16
|
+
transaction<TValue>(operation: () => TValue): () => TValue;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface BunSqliteModule {
|
|
21
|
+
readonly Database: new (path: string, options: { readonly create: boolean; readonly strict: boolean }) => SqliteDatabase;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NodeDatabase {
|
|
25
|
+
exec(sql: string): void;
|
|
26
|
+
prepare(sql: string): SqliteStatement;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface NodeSqliteModule {
|
|
31
|
+
readonly DatabaseSync: new (path: string) => NodeDatabase;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const runtimeRequire = createRequire(import.meta.url);
|
|
35
|
+
|
|
36
|
+
export function openSqliteDatabase(path: string): SqliteDatabase {
|
|
37
|
+
if (process.versions.bun) {
|
|
38
|
+
const sqliteModule = runtimeRequire("bun:sqlite") as BunSqliteModule;
|
|
39
|
+
return new sqliteModule.Database(path, { create: true, strict: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sqliteModule = runtimeRequire("node:sqlite") as NodeSqliteModule;
|
|
43
|
+
const database = new sqliteModule.DatabaseSync(path);
|
|
44
|
+
return {
|
|
45
|
+
exec(sql) {
|
|
46
|
+
database.exec(sql);
|
|
47
|
+
},
|
|
48
|
+
query(sql) {
|
|
49
|
+
return database.prepare(sql);
|
|
50
|
+
},
|
|
51
|
+
transaction(operation) {
|
|
52
|
+
return () => {
|
|
53
|
+
database.exec("BEGIN");
|
|
54
|
+
try {
|
|
55
|
+
const value = operation();
|
|
56
|
+
database.exec("COMMIT");
|
|
57
|
+
return value;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
database.exec("ROLLBACK");
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
close() {
|
|
65
|
+
database.close();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/task-core.ts
ADDED
|
@@ -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
|
+
}
|
package/src/task-inbox.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
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
|
|
|
@@ -19,60 +20,35 @@ interface TaskEventDetails {
|
|
|
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
|
-
if (!modelRelevant(event)) continue;
|
|
38
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) return cursor;
|
|
39
|
-
const eventKey = key(event.taskId, event.id);
|
|
40
|
-
const snapshot = await client.status(event.taskId);
|
|
41
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) return cursor;
|
|
42
|
-
if (incorporated.has(eventKey)) {
|
|
43
|
-
if (event.destination.sessionId === snapshot.task.target.sessionId) await client.delivered(event.taskId, event.id);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (terminalHistoryAcknowledged(snapshot)) continue;
|
|
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)) {
|
|
47
38
|
pi.sendMessage({
|
|
48
39
|
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
49
|
-
content: renderTaskEvent(event
|
|
40
|
+
content: renderTaskEvent(event),
|
|
50
41
|
display: true,
|
|
51
|
-
details: { taskId: event.taskId, eventId: event.
|
|
52
|
-
}, { triggerTurn: true });
|
|
53
|
-
if (!incorporatedEvents(
|
|
54
|
-
incorporated.add(eventKey);
|
|
55
|
-
if (event.destination.sessionId === snapshot.task.target.sessionId) await client.delivered(event.taskId, event.id);
|
|
42
|
+
details: { taskId: event.taskId, eventId: event.eventId },
|
|
43
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
44
|
+
if (!incorporatedEvents(context.sessionManager.getEntries()).has(eventKey)) return;
|
|
56
45
|
}
|
|
57
|
-
|
|
58
|
-
cursor
|
|
59
|
-
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor });
|
|
60
|
-
if (!page.hasMore) return cursor;
|
|
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 });
|
|
61
49
|
}
|
|
62
50
|
}
|
|
63
51
|
|
|
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
52
|
export function restoredInboxCursor(entries: readonly unknown[]): string {
|
|
77
53
|
let cursor = "0";
|
|
78
54
|
for (const entry of entries) {
|
|
@@ -82,46 +58,42 @@ export function restoredInboxCursor(entries: readonly unknown[]): string {
|
|
|
82
58
|
return cursor;
|
|
83
59
|
}
|
|
84
60
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (!isRecord(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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");
|
|
92
69
|
}
|
|
93
70
|
|
|
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}`;
|
|
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);
|
|
105
73
|
}
|
|
106
74
|
|
|
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)}`;
|
|
75
|
+
function isModelVisible(type: string): boolean {
|
|
76
|
+
return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
|
|
113
77
|
}
|
|
114
78
|
|
|
115
|
-
function
|
|
116
|
-
|
|
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}`;
|
|
117
84
|
}
|
|
118
85
|
|
|
119
|
-
function
|
|
120
|
-
|
|
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;
|
|
121
93
|
}
|
|
122
94
|
|
|
123
|
-
function
|
|
124
|
-
return
|
|
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);
|
|
125
97
|
}
|
|
126
98
|
|
|
127
99
|
function key(taskId: string, eventId: string): string {
|
|
@@ -132,10 +104,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
132
104
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
133
105
|
}
|
|
134
106
|
|
|
135
|
-
function nonEmpty(value: unknown): value is string {
|
|
136
|
-
return typeof value === "string" && value.length > 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
107
|
function decimal(value: unknown): value is string {
|
|
140
108
|
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
141
109
|
}
|