@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.
- package/README.md +69 -447
- package/package.json +9 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-pi-task-delegation/SKILL.md +34 -56
- package/src/extension.ts +217 -259
- package/src/gateway-client.ts +409 -0
- 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/metrics-cli.ts +20 -0
- package/src/metrics.ts +288 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-artifacts.ts +153 -0
- package/src/task-board.ts +365 -0
- package/src/task-core.ts +383 -0
- package/src/task-inbox.ts +109 -0
- 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
- package/src/auto-notify.ts +0 -24
- package/src/protocol.ts +0 -73
- package/src/store.ts +0 -494
- package/src/stores/filesystem.ts +0 -40
- package/src/task-communication.ts +0 -68
- package/src/transports/wolfpack.ts +0 -29
- package/src/types.ts +0 -114
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
export const DEFAULT_WOLFPACK_PORT = 18_790;
|
|
2
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
3
|
+
export const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
4
|
+
export const MIN_TASK_TIMEOUT_MS = 1_000;
|
|
5
|
+
export const MAX_TASK_TIMEOUT_MS = 86_400_000;
|
|
6
|
+
// Initial limits; benchmark representative task/context payloads before adjusting them.
|
|
7
|
+
export const MAX_TASK_BYTES = 16 * 1024;
|
|
8
|
+
export const MAX_CONTEXT_SUMMARY_BYTES = 16 * 1024;
|
|
9
|
+
export const MAX_ASSIGNMENT_ENVELOPE_BYTES = 48 * 1024;
|
|
10
|
+
export const MAX_HTTP_BODY_BYTES = 64 * 1024;
|
|
11
|
+
|
|
12
|
+
export type GatewayErrorCode =
|
|
13
|
+
| "ABORTED"
|
|
14
|
+
| "TIMEOUT"
|
|
15
|
+
| "NETWORK_ERROR"
|
|
16
|
+
| "MALFORMED_RESPONSE"
|
|
17
|
+
| "INVALID_RESPONSE"
|
|
18
|
+
| string;
|
|
19
|
+
|
|
20
|
+
export class GatewayClientError extends Error {
|
|
21
|
+
readonly code: GatewayErrorCode;
|
|
22
|
+
readonly retryable: boolean;
|
|
23
|
+
readonly path?: string;
|
|
24
|
+
|
|
25
|
+
constructor(code: GatewayErrorCode, message: string, retryable: boolean, path?: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "GatewayClientError";
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.retryable = retryable;
|
|
30
|
+
this.path = path;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TaskAddress {
|
|
35
|
+
readonly machine: string;
|
|
36
|
+
readonly sessionId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextRef {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly selector?: string;
|
|
42
|
+
readonly purpose?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TaskContext {
|
|
46
|
+
readonly summary?: string;
|
|
47
|
+
readonly refs?: readonly ContextRef[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TaskWarning {
|
|
51
|
+
readonly code: string;
|
|
52
|
+
readonly message: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TaskAssignment {
|
|
56
|
+
readonly taskId: string;
|
|
57
|
+
readonly source: TaskAddress;
|
|
58
|
+
readonly target: TaskAddress;
|
|
59
|
+
readonly task: string;
|
|
60
|
+
readonly createdAt: string;
|
|
61
|
+
readonly expiresAt: string;
|
|
62
|
+
readonly context?: TaskContext;
|
|
63
|
+
readonly role?: string;
|
|
64
|
+
readonly metadata?: Record<string, string>;
|
|
65
|
+
readonly onCompletePrompt?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TaskEvent {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly taskId: string;
|
|
71
|
+
readonly type: string;
|
|
72
|
+
readonly actor: "parent" | "receiver" | "sender";
|
|
73
|
+
readonly source: TaskAddress;
|
|
74
|
+
readonly destination: TaskAddress;
|
|
75
|
+
readonly sequence: string;
|
|
76
|
+
readonly occurredAt: string;
|
|
77
|
+
readonly message?: string;
|
|
78
|
+
readonly replyToMessageId?: string;
|
|
79
|
+
readonly payload: Record<string, unknown>;
|
|
80
|
+
readonly completion?: TaskCompletion;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TaskCompletion {
|
|
84
|
+
readonly summary: string;
|
|
85
|
+
readonly result?: Record<string, unknown>;
|
|
86
|
+
readonly error?: { readonly code: string; readonly message: string; readonly retryable: boolean };
|
|
87
|
+
readonly artifacts?: readonly { readonly machine: string; readonly project: string; readonly path: string; readonly mimeType?: string; readonly description?: string }[];
|
|
88
|
+
readonly warnings: readonly TaskWarning[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TaskStatus {
|
|
92
|
+
readonly task: TaskAssignment;
|
|
93
|
+
readonly status: string;
|
|
94
|
+
readonly events: readonly TaskEvent[];
|
|
95
|
+
readonly completion?: TaskCompletion;
|
|
96
|
+
readonly warnings: readonly TaskWarning[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TaskReceipt {
|
|
100
|
+
readonly taskId: string;
|
|
101
|
+
readonly eventId: string;
|
|
102
|
+
readonly sequence: string;
|
|
103
|
+
readonly warnings: readonly TaskWarning[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface InboxPage {
|
|
107
|
+
readonly events: readonly TaskEvent[];
|
|
108
|
+
readonly nextCursor: string;
|
|
109
|
+
readonly hasMore: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface WolfpackGatewayClientOptions {
|
|
113
|
+
readonly baseUrl?: string;
|
|
114
|
+
readonly port?: string | undefined;
|
|
115
|
+
readonly sessionName?: string | undefined;
|
|
116
|
+
readonly timeoutMs?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SendTaskInput {
|
|
120
|
+
readonly to: TaskAddress;
|
|
121
|
+
readonly task: string;
|
|
122
|
+
readonly context?: TaskContext;
|
|
123
|
+
readonly role?: string;
|
|
124
|
+
readonly preflight?: { readonly requiredProject?: string };
|
|
125
|
+
readonly metadata?: Record<string, string>;
|
|
126
|
+
readonly onCompletePrompt?: string;
|
|
127
|
+
readonly timeoutMs?: number;
|
|
128
|
+
readonly idempotencyKey?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface MessageTaskInput {
|
|
132
|
+
readonly taskId: string;
|
|
133
|
+
readonly type: "question" | "answer" | "information";
|
|
134
|
+
readonly message: string;
|
|
135
|
+
readonly replyToMessageId?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TaskCompletionInput {
|
|
139
|
+
readonly summary: string;
|
|
140
|
+
readonly result?: Record<string, unknown>;
|
|
141
|
+
readonly error?: { readonly code: string; readonly message: string; readonly retryable: boolean };
|
|
142
|
+
readonly artifacts?: readonly { readonly path: string; readonly mimeType?: string; readonly description?: string }[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CompleteTaskInput {
|
|
146
|
+
readonly taskId: string;
|
|
147
|
+
readonly status: "completed" | "failed" | "cancelled";
|
|
148
|
+
readonly result: TaskCompletionInput;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface WolfpackSessionStatus {
|
|
152
|
+
readonly session: string;
|
|
153
|
+
readonly sessionId: string;
|
|
154
|
+
readonly terminal: { readonly exists: boolean; readonly alive: boolean; readonly status: string };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface WolfpackGatewayClient {
|
|
158
|
+
readonly callerSession: string;
|
|
159
|
+
request(method: "GET" | "POST", path: string, body?: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
160
|
+
sessionStatus(signal?: AbortSignal): Promise<WolfpackSessionStatus>;
|
|
161
|
+
send(input: SendTaskInput, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
162
|
+
status(taskId: string, signal?: AbortSignal): Promise<TaskStatus>;
|
|
163
|
+
inbox(cursor: string, includeAcknowledged: boolean, signal?: AbortSignal): Promise<InboxPage>;
|
|
164
|
+
message(input: MessageTaskInput, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
165
|
+
complete(input: CompleteTaskInput, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
166
|
+
cancel(taskId: string, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
167
|
+
delivered(taskId: string, eventId: string, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
168
|
+
ack(taskId: string, signal?: AbortSignal): Promise<TaskReceipt>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createWolfpackGatewayClient(options: WolfpackGatewayClientOptions = {}): WolfpackGatewayClient {
|
|
172
|
+
const baseUrl = options.baseUrl ?? `http://127.0.0.1:${portFrom(options.port ?? process.env.WOLFPACK_PORT)}`;
|
|
173
|
+
const callerSession = options.sessionName ?? process.env.WOLFPACK_SESSION_NAME ?? "unknown-session";
|
|
174
|
+
const requestTimeoutMs = boundedRequestTimeout(options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
175
|
+
|
|
176
|
+
async function request(method: "GET" | "POST", path: string, body?: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
177
|
+
const serializedBody = body === undefined ? undefined : serializeBody(body);
|
|
178
|
+
const timeout = new AbortController();
|
|
179
|
+
const timeoutId = setTimeout(() => timeout.abort(), requestTimeoutMs);
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const abort = (): void => controller.abort();
|
|
182
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
183
|
+
timeout.signal.addEventListener("abort", abort, { once: true });
|
|
184
|
+
try {
|
|
185
|
+
const response = await fetch(new URL(path, baseUrl), {
|
|
186
|
+
method,
|
|
187
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
188
|
+
body: serializedBody,
|
|
189
|
+
signal: controller.signal,
|
|
190
|
+
});
|
|
191
|
+
const parsed = await parseJson(response);
|
|
192
|
+
if (isErrorEnvelope(parsed)) throw new GatewayClientError(parsed.error.code, parsed.error.message, parsed.error.retryable, parsed.error.path);
|
|
193
|
+
if (!response.ok) throw new GatewayClientError("MALFORMED_RESPONSE", `wolfpack returned HTTP ${response.status} without a task error envelope`, true);
|
|
194
|
+
if (!isOkEnvelope(parsed)) throw new GatewayClientError("MALFORMED_RESPONSE", "wolfpack returned an invalid task response", true);
|
|
195
|
+
return parsed;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof GatewayClientError) throw error;
|
|
198
|
+
if (signal?.aborted) throw new GatewayClientError("ABORTED", "wolfpack request was cancelled", true);
|
|
199
|
+
if (timeout.signal.aborted) throw new GatewayClientError("TIMEOUT", `wolfpack request exceeded ${requestTimeoutMs}ms`, true);
|
|
200
|
+
throw new GatewayClientError("NETWORK_ERROR", error instanceof Error ? error.message : "wolfpack request failed", true);
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timeoutId);
|
|
203
|
+
signal?.removeEventListener("abort", abort);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
callerSession,
|
|
209
|
+
request,
|
|
210
|
+
async sessionStatus(signal) {
|
|
211
|
+
return parseSessionStatus(await request("GET", query("/api/session-control/status", { session: callerSession }), undefined, signal));
|
|
212
|
+
},
|
|
213
|
+
async send(input, signal) {
|
|
214
|
+
validateSend(input);
|
|
215
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/send", { callerSession, ...input }, signal));
|
|
216
|
+
},
|
|
217
|
+
async status(taskId, signal) {
|
|
218
|
+
validateId(taskId, "task id");
|
|
219
|
+
return parseStatus(await request("GET", query("/api/tasks/v1/status", { callerSession, taskId }), undefined, signal));
|
|
220
|
+
},
|
|
221
|
+
async inbox(cursor, includeAcknowledged, signal) {
|
|
222
|
+
if (!decimal(cursor)) throw new GatewayClientError("INVALID_REQUEST", "inbox cursor must be a decimal delivery sequence", false);
|
|
223
|
+
return parseInbox(await request("GET", query("/api/tasks/v1/inbox", { callerSession, cursor, includeAcknowledged: String(includeAcknowledged) }), undefined, signal));
|
|
224
|
+
},
|
|
225
|
+
async message(input, signal) {
|
|
226
|
+
validateMessage(input);
|
|
227
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/message", { callerSession, ...input }, signal));
|
|
228
|
+
},
|
|
229
|
+
async complete(input, signal) {
|
|
230
|
+
validateComplete(input);
|
|
231
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/complete", { callerSession, ...input }, signal));
|
|
232
|
+
},
|
|
233
|
+
async cancel(taskId, signal) {
|
|
234
|
+
validateId(taskId, "task id");
|
|
235
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/cancel", { callerSession, taskId }, signal));
|
|
236
|
+
},
|
|
237
|
+
async delivered(taskId, eventId, signal) {
|
|
238
|
+
validateId(taskId, "task id");
|
|
239
|
+
validateId(eventId, "event id");
|
|
240
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/delivered", { callerSession, taskId, eventId }, signal));
|
|
241
|
+
},
|
|
242
|
+
async ack(taskId, signal) {
|
|
243
|
+
validateId(taskId, "task id");
|
|
244
|
+
return parseReceipt(await request("POST", "/api/tasks/v1/ack", { callerSession, taskId }, signal));
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function portFrom(value: string | undefined): number {
|
|
250
|
+
const port = value === undefined ? DEFAULT_WOLFPACK_PORT : Number(value);
|
|
251
|
+
return Number.isInteger(port) && port > 0 && port < 65_536 ? port : DEFAULT_WOLFPACK_PORT;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function boundedRequestTimeout(value: number): number {
|
|
255
|
+
return Number.isInteger(value) && value >= 1 && value <= 60_000 ? value : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function query(path: string, parameters: Record<string, string>): string {
|
|
259
|
+
const search = new URLSearchParams(parameters);
|
|
260
|
+
return `${path}?${search.toString()}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function parseJson(response: Response): Promise<unknown> {
|
|
264
|
+
try {
|
|
265
|
+
return await response.json();
|
|
266
|
+
} catch {
|
|
267
|
+
throw new GatewayClientError("MALFORMED_RESPONSE", "wolfpack returned invalid JSON", true);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function serializeBody(body: unknown): string {
|
|
272
|
+
let serialized: string;
|
|
273
|
+
try {
|
|
274
|
+
serialized = JSON.stringify(body);
|
|
275
|
+
} catch {
|
|
276
|
+
throw new GatewayClientError("INVALID_REQUEST", "task request must be JSON-serializable", false);
|
|
277
|
+
}
|
|
278
|
+
if (bytes(serialized) > MAX_HTTP_BODY_BYTES) throw new GatewayClientError("INVALID_REQUEST", "task request exceeds the 64KiB initial HTTP limit", false);
|
|
279
|
+
return serialized;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateSend(input: SendTaskInput): void {
|
|
283
|
+
const path = sendValidationPath(input);
|
|
284
|
+
if (path !== undefined) throw new GatewayClientError("INVALID_REQUEST", "task and target address are required and bounded", false, path);
|
|
285
|
+
if (input.context?.summary !== undefined && (!nonEmpty(input.context.summary) || bytes(input.context.summary) > MAX_CONTEXT_SUMMARY_BYTES)) throw new GatewayClientError("INVALID_REQUEST", "context summary exceeds the 16KiB initial limit", false, "/context/summary");
|
|
286
|
+
if (bytes(JSON.stringify(input)) > MAX_ASSIGNMENT_ENVELOPE_BYTES) throw new GatewayClientError("INVALID_REQUEST", "assignment envelope exceeds the 48KiB initial limit", false, "");
|
|
287
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
|
|
288
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TASK_TIMEOUT_MS || timeoutMs > MAX_TASK_TIMEOUT_MS) throw new GatewayClientError("INVALID_REQUEST", "task timeout must be from 1000ms through 24h", false, "/timeoutMs");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sendValidationPath(input: SendTaskInput): string | undefined {
|
|
292
|
+
if (!isRecord(input.to)) return "/to";
|
|
293
|
+
if (!nonEmpty(input.to.machine)) return "/to/machine";
|
|
294
|
+
if (!nonEmpty(input.to.sessionId)) return "/to/sessionId";
|
|
295
|
+
if (!nonEmpty(input.task) || bytes(input.task) > MAX_TASK_BYTES) return "/task";
|
|
296
|
+
if (input.preflight?.requiredProject !== undefined && !nonEmpty(input.preflight.requiredProject)) return "/preflight/requiredProject";
|
|
297
|
+
if (input.context?.refs !== undefined) {
|
|
298
|
+
if (!Array.isArray(input.context.refs)) return "/context/refs";
|
|
299
|
+
for (const [index, ref] of input.context.refs.entries()) {
|
|
300
|
+
if (!isRecord(ref)) return `/context/refs/${index}`;
|
|
301
|
+
if (!nonEmpty(ref.path)) return `/context/refs/${index}/path`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validateMessage(input: MessageTaskInput): void {
|
|
308
|
+
validateId(input.taskId, "task id");
|
|
309
|
+
if (!nonEmpty(input.message) || bytes(input.message) > MAX_TASK_BYTES || !["question", "answer", "information"].includes(input.type) || (input.replyToMessageId !== undefined && !nonEmpty(input.replyToMessageId))) throw new GatewayClientError("INVALID_REQUEST", "task message is invalid or exceeds the 16KiB initial limit", false);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function validateComplete(input: CompleteTaskInput): void {
|
|
313
|
+
validateId(input.taskId, "task id");
|
|
314
|
+
if (!nonEmpty(input.result.summary) || bytes(input.result.summary) > MAX_TASK_BYTES || !["completed", "failed", "cancelled"].includes(input.status)) throw new GatewayClientError("INVALID_REQUEST", "terminal task result is invalid or exceeds the 16KiB initial limit", false);
|
|
315
|
+
if (input.result.artifacts?.some((artifact) => !nonEmpty(artifact.path))) throw new GatewayClientError("INVALID_REQUEST", "artifact paths must be non-empty", false);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function validateId(value: string, label: string): void {
|
|
319
|
+
if (!nonEmpty(value)) throw new GatewayClientError("INVALID_REQUEST", `${label} is required`, false);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseSessionStatus(value: unknown): WolfpackSessionStatus {
|
|
323
|
+
if (!isRecord(value) || value.ok !== true || !nonEmpty(value.session) || !nonEmpty(value.sessionId) || !isRecord(value.terminal) || typeof value.terminal.exists !== "boolean" || typeof value.terminal.alive !== "boolean" || !nonEmpty(value.terminal.status)) throw invalidResponse("invalid structured Wolfpack session status");
|
|
324
|
+
return { session: value.session, sessionId: value.sessionId, terminal: { exists: value.terminal.exists, alive: value.terminal.alive, status: value.terminal.status } };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseReceipt(value: unknown): TaskReceipt {
|
|
328
|
+
if (!isRecord(value) || !nonEmpty(value.taskId) || !nonEmpty(value.eventId) || !decimal(value.sequence) || (value.warnings !== undefined && !warnings(value.warnings))) throw invalidResponse("invalid task acknowledgement");
|
|
329
|
+
return { taskId: value.taskId, eventId: value.eventId, sequence: value.sequence, warnings: value.warnings ?? [] };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseStatus(value: unknown): TaskStatus {
|
|
333
|
+
if (!isRecord(value) || !assignment(value.task) || !nonEmpty(value.status) || !events(value.events) || !warnings(value.warnings) || (value.completion !== undefined && !completion(value.completion))) throw invalidResponse("invalid task status response");
|
|
334
|
+
return { task: value.task, status: value.status, events: value.events, warnings: value.warnings, ...(value.completion && { completion: value.completion }) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseInbox(value: unknown): InboxPage {
|
|
338
|
+
if (!isRecord(value) || !events(value.events) || !decimal(value.nextCursor) || typeof value.hasMore !== "boolean") throw invalidResponse("invalid task inbox response");
|
|
339
|
+
return { events: value.events, nextCursor: value.nextCursor, hasMore: value.hasMore };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function invalidResponse(message: string): GatewayClientError {
|
|
343
|
+
return new GatewayClientError("INVALID_RESPONSE", message, true);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isOkEnvelope(value: unknown): value is Record<string, unknown> & { readonly ok: true } {
|
|
347
|
+
return isRecord(value) && value.ok === true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isErrorEnvelope(value: unknown): value is { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean; readonly path?: string } } {
|
|
351
|
+
return isRecord(value) && value.ok === false && isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean"
|
|
352
|
+
&& (value.error.path === undefined || jsonPointer(value.error.path));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function jsonPointer(value: unknown): value is string {
|
|
356
|
+
return typeof value === "string" && /^(?:|\/(?:[^~/]|~[01])*)*$/.test(value);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function assignment(value: unknown): value is TaskAssignment {
|
|
360
|
+
return isRecord(value) && nonEmpty(value.taskId) && isAddress(value.source) && isAddress(value.target) && nonEmpty(value.task) && iso(value.createdAt) && iso(value.expiresAt)
|
|
361
|
+
&& (value.context === undefined || context(value.context)) && (value.role === undefined || nonEmpty(value.role)) && (value.metadata === undefined || stringRecord(value.metadata)) && (value.onCompletePrompt === undefined || nonEmpty(value.onCompletePrompt));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function context(value: unknown): value is TaskContext {
|
|
365
|
+
return isRecord(value) && (value.summary === undefined || nonEmpty(value.summary)) && (value.refs === undefined || (Array.isArray(value.refs) && value.refs.every((ref) => isRecord(ref) && nonEmpty(ref.path) && (ref.selector === undefined || typeof ref.selector === "string") && (ref.purpose === undefined || typeof ref.purpose === "string"))));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function events(value: unknown): value is readonly TaskEvent[] {
|
|
369
|
+
return Array.isArray(value) && value.every((event) => isRecord(event) && nonEmpty(event.id) && nonEmpty(event.taskId) && nonEmpty(event.type) && (event.actor === "parent" || event.actor === "receiver" || event.actor === "sender") && isAddress(event.source) && isAddress(event.destination) && decimal(event.sequence) && iso(event.occurredAt) && isRecord(event.payload) && (event.message === undefined || typeof event.message === "string") && (event.replyToMessageId === undefined || nonEmpty(event.replyToMessageId)) && (event.completion === undefined || completion(event.completion)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function completion(value: unknown): value is TaskCompletion {
|
|
373
|
+
return isRecord(value) && nonEmpty(value.summary) && warnings(value.warnings)
|
|
374
|
+
&& (value.result === undefined || isRecord(value.result))
|
|
375
|
+
&& (value.error === undefined || (isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean"))
|
|
376
|
+
&& (value.artifacts === undefined || (Array.isArray(value.artifacts) && value.artifacts.every((artifact) => isRecord(artifact) && nonEmpty(artifact.machine) && nonEmpty(artifact.project) && nonEmpty(artifact.path) && (artifact.mimeType === undefined || typeof artifact.mimeType === "string") && (artifact.description === undefined || typeof artifact.description === "string"))));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function warnings(value: unknown): value is readonly TaskWarning[] {
|
|
380
|
+
return Array.isArray(value) && value.every((warning) => isRecord(warning) && nonEmpty(warning.code) && typeof warning.message === "string");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function stringRecord(value: unknown): value is Record<string, string> {
|
|
384
|
+
return isRecord(value) && Object.values(value).every((field) => typeof field === "string");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isAddress(value: unknown): value is TaskAddress {
|
|
388
|
+
return isRecord(value) && nonEmpty(value.machine) && nonEmpty(value.sessionId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
392
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function nonEmpty(value: unknown): value is string {
|
|
396
|
+
return typeof value === "string" && value.length > 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function decimal(value: unknown): value is string {
|
|
400
|
+
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function iso(value: unknown): value is string {
|
|
404
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function bytes(value: string): number {
|
|
408
|
+
return new TextEncoder().encode(value).byteLength;
|
|
409
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_RELAY_PAYLOAD_BYTES,
|
|
3
|
+
TASK_PROTOCOL_VERSION,
|
|
4
|
+
TaskProtocolError,
|
|
5
|
+
} from "./task-protocol";
|
|
6
|
+
import type {
|
|
7
|
+
RelayAcceptance,
|
|
8
|
+
RelayConnectInput,
|
|
9
|
+
RelayConnection,
|
|
10
|
+
RelayDeliveryAck,
|
|
11
|
+
RelayEnvelope,
|
|
12
|
+
RelayInboxPage,
|
|
13
|
+
RelayReceiveRequest,
|
|
14
|
+
RelayTargetReference,
|
|
15
|
+
TaskEndpoint,
|
|
16
|
+
TaskRelay,
|
|
17
|
+
} from "./task-protocol";
|
|
18
|
+
|
|
19
|
+
interface MailboxItem {
|
|
20
|
+
readonly cursor: string;
|
|
21
|
+
readonly envelope: RelayEnvelope;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Deterministic conformance fixture; production relays must provide durable storage. */
|
|
25
|
+
export function createInMemoryTaskRelay(id: string): InMemoryTaskRelay {
|
|
26
|
+
return new InMemoryTaskRelay(id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class InMemoryTaskRelay implements TaskRelay {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
private readonly endpoints = new Map<string, TaskEndpoint>();
|
|
32
|
+
private readonly mailboxes = new Map<string, MailboxItem[]>();
|
|
33
|
+
private readonly accepted = new Set<string>();
|
|
34
|
+
private readonly acknowledged = new Map<string, number>();
|
|
35
|
+
private nextDelivery = 0;
|
|
36
|
+
private sendFailure = false;
|
|
37
|
+
|
|
38
|
+
constructor(id: string) {
|
|
39
|
+
this.id = id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async connect(input: RelayConnectInput): Promise<RelayConnection> {
|
|
43
|
+
if (input.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "task endpoint protocol is incompatible");
|
|
44
|
+
this.assertEndpoint(input.endpoint);
|
|
45
|
+
this.endpoints.set(input.endpoint.id, input.endpoint);
|
|
46
|
+
if (!this.mailboxes.has(input.endpoint.id)) this.mailboxes.set(input.endpoint.id, []);
|
|
47
|
+
return { endpoint: input.endpoint, receiveCursor: input.receiveCursor };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async resolve(input: RelayTargetReference): Promise<TaskEndpoint> {
|
|
51
|
+
if (input.relay !== this.id) throw new TaskProtocolError("UNKNOWN_RELAY", "target belongs to another relay");
|
|
52
|
+
const endpoint = this.endpoints.get(input.reference);
|
|
53
|
+
if (!endpoint) throw new TaskProtocolError("UNREGISTERED_TARGET", "target is not registered for this protocol");
|
|
54
|
+
return endpoint;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async send(input: RelayEnvelope): Promise<RelayAcceptance> {
|
|
58
|
+
if (this.sendFailure) {
|
|
59
|
+
this.sendFailure = false;
|
|
60
|
+
throw new Error("in-memory relay send failed");
|
|
61
|
+
}
|
|
62
|
+
this.assertEnvelope(input);
|
|
63
|
+
if (this.accepted.has(input.envelopeId)) return { envelopeId: input.envelopeId };
|
|
64
|
+
const mailbox = this.mailboxes.get(input.target.id);
|
|
65
|
+
if (!mailbox) throw new TaskProtocolError("UNREGISTERED_TARGET", "target is not registered for this protocol");
|
|
66
|
+
this.accepted.add(input.envelopeId);
|
|
67
|
+
mailbox.push({ cursor: String(++this.nextDelivery), envelope: input });
|
|
68
|
+
return { envelopeId: input.envelopeId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async receive(input: RelayReceiveRequest): Promise<RelayInboxPage> {
|
|
72
|
+
this.assertEndpoint(input.endpoint);
|
|
73
|
+
const mailbox = this.mailboxes.get(input.endpoint.id);
|
|
74
|
+
if (!mailbox) throw new TaskProtocolError("UNREGISTERED_TARGET", "endpoint is not registered for this protocol");
|
|
75
|
+
const acknowledged = this.acknowledged.get(input.endpoint.id) ?? 0;
|
|
76
|
+
const cursor = Number(input.cursor);
|
|
77
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
78
|
+
const start = Math.max(acknowledged, cursor);
|
|
79
|
+
const pending = mailbox.filter((item) => Number(item.cursor) > start);
|
|
80
|
+
const deliveries = pending.slice(0, input.limit);
|
|
81
|
+
return {
|
|
82
|
+
deliveries,
|
|
83
|
+
nextCursor: deliveries.at(-1)?.cursor ?? String(start),
|
|
84
|
+
hasMore: pending.length > deliveries.length,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async acknowledgeDelivery(input: RelayDeliveryAck): Promise<void> {
|
|
89
|
+
this.assertEndpoint(input.endpoint);
|
|
90
|
+
const cursor = Number(input.cursor);
|
|
91
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
92
|
+
this.acknowledged.set(input.endpoint.id, Math.max(this.acknowledged.get(input.endpoint.id) ?? 0, cursor));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
failNextSend(): void {
|
|
96
|
+
this.sendFailure = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
envelopesFor(endpoint: TaskEndpoint): readonly RelayEnvelope[] {
|
|
100
|
+
return (this.mailboxes.get(endpoint.id) ?? []).map((item) => item.envelope);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private assertEndpoint(endpoint: TaskEndpoint): void {
|
|
104
|
+
if (endpoint.relay !== this.id || endpoint.id.length === 0) throw new TaskProtocolError("INVALID_ENDPOINT", "endpoint must belong to this relay and have an opaque id");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private assertEnvelope(envelope: RelayEnvelope): void {
|
|
108
|
+
if (envelope.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "envelope protocol is incompatible");
|
|
109
|
+
this.assertEndpoint(envelope.source);
|
|
110
|
+
this.assertEndpoint(envelope.target);
|
|
111
|
+
if (!this.endpoints.has(envelope.source.id)) throw new TaskProtocolError("UNREGISTERED_SOURCE", "source is not registered for this protocol");
|
|
112
|
+
if (new TextEncoder().encode(envelope.payload).byteLength > MAX_RELAY_PAYLOAD_BYTES) throw new TaskProtocolError("PAYLOAD_TOO_LARGE", "relay payload exceeds the protocol limit");
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { createInMemoryTaskRelay, InMemoryTaskRelay } from "./in-memory-task-relay";
|
|
2
|
+
export { createTaskCore } from "./task-core";
|
|
3
|
+
export { createTaskStore } from "./task-store";
|
|
4
|
+
export {
|
|
5
|
+
createWolfpackTaskCore,
|
|
6
|
+
createWolfpackTaskRelay,
|
|
7
|
+
WOLFPACK_TASK_RELAY_ID,
|
|
8
|
+
WOLFPACK_TASK_RELAY_LEASE_MS,
|
|
9
|
+
WOLFPACK_TASK_RELAY_PROTOCOL_VERSION,
|
|
10
|
+
} from "./wolfpack-task-relay";
|
|
11
|
+
export { runTaskRelayConformance } from "./task-relay-conformance";
|
|
12
|
+
export {
|
|
13
|
+
MAX_RELAY_PAYLOAD_BYTES,
|
|
14
|
+
TASK_PROTOCOL_VERSION,
|
|
15
|
+
TaskEnvelopeKind,
|
|
16
|
+
TaskProtocolError,
|
|
17
|
+
} from "./task-protocol";
|
|
18
|
+
export type {
|
|
19
|
+
RelayAcceptance,
|
|
20
|
+
RelayConnectInput,
|
|
21
|
+
RelayConnection,
|
|
22
|
+
RelayDelivery,
|
|
23
|
+
RelayDeliveryAck,
|
|
24
|
+
RelayEnvelope,
|
|
25
|
+
RelayInboxPage,
|
|
26
|
+
RelayReceiveRequest,
|
|
27
|
+
RelayTargetReference,
|
|
28
|
+
TaskEndpoint,
|
|
29
|
+
TaskEvent,
|
|
30
|
+
TaskIntent,
|
|
31
|
+
TaskRecord,
|
|
32
|
+
TaskRelay,
|
|
33
|
+
} from "./task-protocol";
|
|
34
|
+
export type { TaskCore, TaskCoreOptions } from "./task-core";
|
|
35
|
+
export type { TaskStore, TaskStoreOptions } from "./task-store";
|