@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.
@@ -0,0 +1,343 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { createTaskCore } from "./task-core";
6
+ import { createTaskStore } from "./task-store";
7
+ import { TASK_PROTOCOL_VERSION, TaskProtocolError } from "./task-protocol";
8
+ import type {
9
+ RelayAcceptance,
10
+ RelayConnectInput,
11
+ RelayConnection,
12
+ RelayDeliveryAck,
13
+ RelayEnvelope,
14
+ RelayInboxPage,
15
+ RelayReceiveRequest,
16
+ RelayTargetReference,
17
+ TaskEndpoint,
18
+ TaskRelay,
19
+ } from "./task-protocol";
20
+ import type { TaskCoreOptions } from "./task-core";
21
+ import type { TaskStore, TaskStoreOptions } from "./task-store";
22
+
23
+ export const WOLFPACK_TASK_RELAY_ID = "wolfpack-pi-tasks-v2";
24
+ export const WOLFPACK_TASK_RELAY_PROTOCOL_VERSION = 2;
25
+ export const WOLFPACK_TASK_RELAY_LEASE_MS = 60_000;
26
+ const DEFAULT_WOLFPACK_PORT = 18_790;
27
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
28
+ const MAX_REQUEST_TIMEOUT_MS = 60_000;
29
+ const REGISTRATION_POLL_MARGIN_MS = 10_000;
30
+ const SESSION_STORE_DIRECTORY = "sessions";
31
+ const ENDPOINT_ROTATED_CODE = "ENDPOINT_ROTATED";
32
+
33
+ export interface WolfpackTaskRelayOptions {
34
+ readonly baseUrl?: string;
35
+ readonly sessionName?: string;
36
+ readonly generation?: string;
37
+ readonly leaseMs?: number;
38
+ readonly requestTimeoutMs?: number;
39
+ readonly fetch?: typeof fetch;
40
+ }
41
+
42
+ export interface WolfpackTaskCoreOptions extends WolfpackTaskRelayOptions, TaskStoreOptions {
43
+ readonly clock?: TaskCoreOptions["clock"];
44
+ readonly ids?: TaskCoreOptions["ids"];
45
+ }
46
+
47
+ export interface WolfpackTaskRelay extends TaskRelay {
48
+ endpoint(signal?: AbortSignal): Promise<TaskEndpoint>;
49
+ }
50
+
51
+ /** Returns the isolated durable store path for one Wolfpack session. */
52
+ export function wolfpackTaskStorePath(sessionName: string): string {
53
+ if (!nonEmpty(sessionName)) throw new TaskProtocolError("RELAY_UNAVAILABLE", "WOLFPACK_SESSION_NAME is required for the Wolfpack relay v2 adapter");
54
+ const sessionKey = createHash("sha256").update(sessionName).digest("hex");
55
+ return join(homedir(), ".pi", "tasks", "v2", SESSION_STORE_DIRECTORY, sessionKey, "tasks.sqlite");
56
+ }
57
+
58
+ interface WolfpackRelayEnvelope {
59
+ readonly envelopeId: string;
60
+ readonly protocolVersion: number;
61
+ readonly source: TaskEndpoint;
62
+ readonly target: TaskEndpoint;
63
+ readonly payload: unknown;
64
+ readonly createdAt: string;
65
+ }
66
+
67
+ interface WolfpackRelayResponse {
68
+ readonly ok: true;
69
+ }
70
+
71
+ interface WolfpackConnectResponse extends WolfpackRelayResponse {
72
+ readonly endpoint: TaskEndpoint;
73
+ readonly leaseExpiresAt: string;
74
+ }
75
+
76
+ interface WolfpackResolveResponse extends WolfpackRelayResponse {
77
+ readonly endpoint: TaskEndpoint;
78
+ }
79
+
80
+ interface WolfpackSendResponse extends WolfpackRelayResponse {
81
+ readonly acceptanceId: string;
82
+ }
83
+
84
+ interface WolfpackReceiveResponse extends WolfpackRelayResponse {
85
+ readonly envelopes: readonly WolfpackRelayEnvelope[];
86
+ readonly nextCursor: string;
87
+ readonly hasMore: boolean;
88
+ }
89
+
90
+ interface Registration {
91
+ readonly endpoint: TaskEndpoint;
92
+ readonly expiresAt: number;
93
+ }
94
+
95
+ /** Creates a production relay backed by the local Wolfpack relay v2 HTTP contract. */
96
+ export function createWolfpackTaskRelay(options: WolfpackTaskRelayOptions = {}): WolfpackTaskRelay {
97
+ const callerSession = options.sessionName ?? process.env.WOLFPACK_SESSION_NAME;
98
+ const baseUrl = options.baseUrl ?? `http://127.0.0.1:${portFrom(process.env.WOLFPACK_PORT)}`;
99
+ const generation = options.generation ?? crypto.randomUUID();
100
+ const requestTimeoutMs = boundedRequestTimeout(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
101
+ const registrationRenewalWindowMs = requestTimeoutMs + REGISTRATION_POLL_MARGIN_MS;
102
+ const minimumLeaseMs = requestTimeoutMs + registrationRenewalWindowMs;
103
+ const leaseMs = Math.max(options.leaseMs ?? WOLFPACK_TASK_RELAY_LEASE_MS, minimumLeaseMs);
104
+ const requestFetch = options.fetch ?? fetch;
105
+ const envelopeIds = new Map<string, string>();
106
+ let registration: Registration | undefined;
107
+
108
+ const register = async (signal?: AbortSignal): Promise<TaskEndpoint> => {
109
+ if (registration && registration.expiresAt - Date.now() > registrationRenewalWindowMs) return registration.endpoint;
110
+ if (!nonEmpty(callerSession)) throw new TaskProtocolError("RELAY_UNAVAILABLE", "WOLFPACK_SESSION_NAME is required for the Wolfpack relay v2 adapter");
111
+ const response = await request<WolfpackConnectResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/connect", {
112
+ callerSession,
113
+ generation,
114
+ protocolVersions: [WOLFPACK_TASK_RELAY_PROTOCOL_VERSION],
115
+ leaseMs,
116
+ }, signal);
117
+ if (!isEndpoint(response.endpoint) || !futureTimestamp(response.leaseExpiresAt)) throw new TaskProtocolError("INVALID_CONNECTION", "Wolfpack relay returned an invalid registration lease");
118
+ registration = { endpoint: response.endpoint, expiresAt: Date.parse(response.leaseExpiresAt) };
119
+ return registration.endpoint;
120
+ };
121
+
122
+ return {
123
+ id: WOLFPACK_TASK_RELAY_ID,
124
+ endpoint: register,
125
+ async connect(input: RelayConnectInput, signal?: AbortSignal): Promise<RelayConnection> {
126
+ if (input.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "task endpoint protocol is incompatible");
127
+ const registered = await register(signal);
128
+ if (!sameEndpoint(input.endpoint, registered)) throw new TaskProtocolError("INVALID_CONNECTION", "task core endpoint does not match the Wolfpack relay registration");
129
+ return { endpoint: registered, receiveCursor: input.receiveCursor };
130
+ },
131
+ async resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint> {
132
+ await register(signal);
133
+ const response = await request<WolfpackResolveResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/resolve", {
134
+ callerSession: requiredSession(callerSession),
135
+ target: { relay: input.relay, id: input.reference },
136
+ protocolVersion: WOLFPACK_TASK_RELAY_PROTOCOL_VERSION,
137
+ }, signal);
138
+ if (!isEndpoint(response.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "Wolfpack relay returned an invalid target endpoint");
139
+ return response.endpoint;
140
+ },
141
+ async send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance> {
142
+ await register(signal);
143
+ const response = await request<WolfpackSendResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/send", {
144
+ callerSession: requiredSession(callerSession),
145
+ envelope: toWolfpackEnvelope(input),
146
+ }, signal);
147
+ if (!nonEmpty(response.acceptanceId)) throw new TaskProtocolError("INVALID_ACCEPTANCE", "Wolfpack relay returned an invalid acceptance");
148
+ return { envelopeId: input.envelopeId };
149
+ },
150
+ async receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage> {
151
+ await register(signal);
152
+ const response = await request<WolfpackReceiveResponse>(requestFetch, baseUrl, requestTimeoutMs, "GET", "/api/task-relay/v2/receive", undefined, signal, {
153
+ callerSession: requiredSession(callerSession),
154
+ cursor: input.cursor,
155
+ });
156
+ if (!Array.isArray(response.envelopes) || !decimal(response.nextCursor) || typeof response.hasMore !== "boolean") throw new TaskProtocolError("INVALID_INBOX", "Wolfpack relay returned an invalid inbox page");
157
+ const start = Number(input.cursor);
158
+ if (!Number.isSafeInteger(start) || start < 0) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
159
+ const envelopes = response.envelopes.slice(0, input.limit).map(fromWolfpackEnvelope);
160
+ const deliveries = envelopes.map((envelope, index) => {
161
+ const cursor = String(start + index + 1);
162
+ envelopeIds.set(cursor, envelope.envelopeId);
163
+ return { cursor, envelope };
164
+ });
165
+ return { deliveries, nextCursor: response.nextCursor, hasMore: response.hasMore || response.envelopes.length > envelopes.length };
166
+ },
167
+ async acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void> {
168
+ await register(signal);
169
+ const envelopeId = envelopeIds.get(input.cursor);
170
+ if (!envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery cursor is not available for acknowledgement");
171
+ await request<WolfpackRelayResponse>(requestFetch, baseUrl, requestTimeoutMs, "POST", "/api/task-relay/v2/delivery-ack", { callerSession: requiredSession(callerSession), envelopeId }, signal);
172
+ envelopeIds.delete(input.cursor);
173
+ },
174
+ };
175
+ }
176
+
177
+ /** Registers this process, then returns a core whose endpoint is the registered opaque Wolfpack endpoint. */
178
+ export async function createWolfpackTaskCore(options: WolfpackTaskCoreOptions = {}, signal?: AbortSignal): Promise<ReturnType<typeof createTaskCore>> {
179
+ const sessionName = options.sessionName ?? process.env.WOLFPACK_SESSION_NAME;
180
+ const store = createTaskStore({ path: options.path ?? wolfpackTaskStorePath(requiredSession(sessionName)) });
181
+ const generation = options.generation ?? store.getEndpointGeneration() ?? crypto.randomUUID();
182
+ store.transaction(() => { store.setEndpointGeneration(generation); });
183
+ const relay = createWolfpackTaskRelay({ ...options, sessionName, generation });
184
+ try {
185
+ const endpoint = await relay.endpoint(signal);
186
+ bindRegisteredEndpoint(store, endpoint, options.clock?.now() ?? Date.now());
187
+ return createTaskCore({ endpoint, relay, store, ...(options.clock === undefined ? {} : { clock: options.clock }), ...(options.ids === undefined ? {} : { ids: options.ids }) });
188
+ } catch (error) {
189
+ store.close();
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ function bindRegisteredEndpoint(store: TaskStore, endpoint: TaskEndpoint, now: number): void {
195
+ const priorEndpoint = store.getEndpointBinding();
196
+ if (!priorEndpoint) {
197
+ store.transaction(() => { store.setEndpointBinding(endpoint); });
198
+ return;
199
+ }
200
+ if (sameEndpoint(priorEndpoint, endpoint)) return;
201
+ store.transaction(() => {
202
+ store.setEndpointBinding(endpoint);
203
+ store.setReceiveCursor("0");
204
+ for (const record of store.outbox("pending")) {
205
+ if (!sameEndpoint(record.envelope.source, priorEndpoint)) continue;
206
+ store.quarantineOutbox(record.envelope.envelopeId, {
207
+ errorCode: ENDPOINT_ROTATED_CODE,
208
+ reason: "source endpoint rotated before delivery",
209
+ details: { priorEndpoint, endpoint },
210
+ quarantinedAt: now,
211
+ });
212
+ }
213
+ });
214
+ }
215
+
216
+ function toWolfpackEnvelope(envelope: RelayEnvelope): WolfpackRelayEnvelope {
217
+ let payload: unknown;
218
+ try {
219
+ payload = JSON.parse(envelope.payload) as unknown;
220
+ } catch {
221
+ throw new TaskProtocolError("INVALID_PAYLOAD", "task relay envelope payload is not valid JSON");
222
+ }
223
+ return {
224
+ envelopeId: envelope.envelopeId,
225
+ protocolVersion: WOLFPACK_TASK_RELAY_PROTOCOL_VERSION,
226
+ source: envelope.source,
227
+ target: envelope.target,
228
+ payload: { taskId: envelope.taskId, kind: envelope.kind, payload },
229
+ createdAt: new Date().toISOString(),
230
+ };
231
+ }
232
+
233
+ function fromWolfpackEnvelope(envelope: WolfpackRelayEnvelope): RelayEnvelope {
234
+ if (!nonEmpty(envelope.envelopeId) || envelope.protocolVersion !== WOLFPACK_TASK_RELAY_PROTOCOL_VERSION || !isEndpoint(envelope.source) || !isEndpoint(envelope.target)) {
235
+ throw new TaskProtocolError("INVALID_ENVELOPE", "Wolfpack relay returned an invalid envelope");
236
+ }
237
+ const taskId = taskIdFromPayload(envelope.payload);
238
+ const kind = envelopeKindFromPayload(envelope.payload);
239
+ const payload = JSON.stringify(relayPayload(envelope.payload));
240
+ if (payload === undefined) throw new TaskProtocolError("INVALID_PAYLOAD", "Wolfpack relay envelope payload is not JSON-serializable");
241
+ return { envelopeId: envelope.envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source: envelope.source, target: envelope.target, taskId, kind, payload };
242
+ }
243
+
244
+ function taskIdFromPayload(payload: unknown): string {
245
+ if (!isRecord(payload) || !nonEmpty(payload.taskId)) throw new TaskProtocolError("INVALID_ENVELOPE", "Wolfpack relay payload does not identify a task");
246
+ return payload.taskId;
247
+ }
248
+
249
+ function envelopeKindFromPayload(payload: unknown): RelayEnvelope["kind"] {
250
+ if (!isRecord(payload) || typeof payload.kind !== "string") throw new TaskProtocolError("INVALID_ENVELOPE", "Wolfpack relay payload does not identify an envelope kind");
251
+ if (payload.kind === "assignment" || payload.kind === "intent" || payload.kind === "canonical_event") return payload.kind;
252
+ throw new TaskProtocolError("INVALID_ENVELOPE", "Wolfpack relay payload has an invalid envelope kind");
253
+ }
254
+
255
+ function relayPayload(payload: unknown): unknown {
256
+ if (!isRecord(payload) || !("payload" in payload)) throw new TaskProtocolError("INVALID_ENVELOPE", "Wolfpack relay payload is missing its opaque body");
257
+ return payload.payload;
258
+ }
259
+
260
+ async function request<TResponse extends WolfpackRelayResponse>(requestFetch: typeof fetch, baseUrl: string, requestTimeoutMs: number, method: "GET" | "POST", path: string, body?: unknown, signal?: AbortSignal, query?: Record<string, string>): Promise<TResponse> {
261
+ const timeout = new AbortController();
262
+ const initialAbortError = requestAbortError(signal, timeout.signal, requestTimeoutMs);
263
+ if (initialAbortError) throw initialAbortError;
264
+ const controller = new AbortController();
265
+ const abort = (): void => controller.abort();
266
+ const timeoutId = setTimeout(() => timeout.abort(), requestTimeoutMs);
267
+ signal?.addEventListener("abort", abort, { once: true });
268
+ timeout.signal.addEventListener("abort", abort, { once: true });
269
+ try {
270
+ let response: Response;
271
+ try {
272
+ const url = new URL(path, baseUrl);
273
+ for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, value);
274
+ response = await requestFetch(url, body === undefined ? { method, signal: controller.signal } : { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: controller.signal });
275
+ } catch (error) {
276
+ const abortError = requestAbortError(signal, timeout.signal, requestTimeoutMs);
277
+ if (abortError) throw abortError;
278
+ throw new TaskProtocolError("RELAY_UNAVAILABLE", error instanceof Error ? error.message : "Wolfpack relay request failed");
279
+ }
280
+ let payload: unknown;
281
+ try {
282
+ payload = await response.json();
283
+ } catch {
284
+ const abortError = requestAbortError(signal, timeout.signal, requestTimeoutMs);
285
+ if (abortError) throw abortError;
286
+ throw new TaskProtocolError("INVALID_RESPONSE", "Wolfpack relay returned invalid JSON");
287
+ }
288
+ if (!isRecord(payload)) throw new TaskProtocolError("INVALID_RESPONSE", "Wolfpack relay returned an invalid response");
289
+ if (payload.ok === false && isRecord(payload.error) && nonEmpty(payload.error.code) && typeof payload.error.message === "string" && typeof payload.error.retryable === "boolean") {
290
+ throw new TaskProtocolError(payload.error.code, payload.error.message, { retryable: payload.error.retryable });
291
+ }
292
+ if (!response.ok || payload.ok !== true) throw new TaskProtocolError("INVALID_RESPONSE", "Wolfpack relay returned an invalid response");
293
+ return payload as TResponse;
294
+ } finally {
295
+ clearTimeout(timeoutId);
296
+ signal?.removeEventListener("abort", abort);
297
+ timeout.signal.removeEventListener("abort", abort);
298
+ }
299
+ }
300
+
301
+ function requestAbortError(signal: AbortSignal | undefined, timeoutSignal: AbortSignal, requestTimeoutMs: number): TaskProtocolError | undefined {
302
+ if (signal?.aborted) return new TaskProtocolError("ABORTED", "task relay request was cancelled");
303
+ if (timeoutSignal.aborted) return new TaskProtocolError("RELAY_TIMEOUT", `task relay request exceeded ${requestTimeoutMs}ms`);
304
+ return undefined;
305
+ }
306
+
307
+ function requiredSession(sessionName: string | undefined): string {
308
+ if (!nonEmpty(sessionName)) throw new TaskProtocolError("RELAY_UNAVAILABLE", "WOLFPACK_SESSION_NAME is required for the Wolfpack relay v2 adapter");
309
+ return sessionName;
310
+ }
311
+
312
+ function boundedRequestTimeout(value: number): number {
313
+ return Number.isInteger(value) && value >= 1 && value <= MAX_REQUEST_TIMEOUT_MS ? value : DEFAULT_REQUEST_TIMEOUT_MS;
314
+ }
315
+
316
+ function futureTimestamp(value: unknown): value is string {
317
+ return typeof value === "string" && Number.isFinite(Date.parse(value)) && Date.parse(value) > Date.now();
318
+ }
319
+
320
+ function portFrom(value: string | undefined): number {
321
+ const port = value === undefined ? DEFAULT_WOLFPACK_PORT : Number(value);
322
+ return Number.isInteger(port) && port > 0 && port < 65_536 ? port : DEFAULT_WOLFPACK_PORT;
323
+ }
324
+
325
+ function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
326
+ return left.relay === right.relay && left.id === right.id;
327
+ }
328
+
329
+ function isEndpoint(value: unknown): value is TaskEndpoint {
330
+ return isRecord(value) && nonEmpty(value.relay) && nonEmpty(value.id);
331
+ }
332
+
333
+ function isRecord(value: unknown): value is Record<string, unknown> {
334
+ return value !== null && typeof value === "object" && !Array.isArray(value);
335
+ }
336
+
337
+ function nonEmpty(value: unknown): value is string {
338
+ return typeof value === "string" && value.length > 0;
339
+ }
340
+
341
+ function decimal(value: unknown): value is string {
342
+ return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
343
+ }