@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 0.2.0
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 +213 -2
- package/docs/protocol-reference.md +124 -0
- package/package.json +55 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-task-delegation/SKILL.md +107 -0
- package/src/abortable-sleep.ts +25 -0
- package/src/agent-task-runtime.ts +709 -0
- package/src/agent-task-tool-schemas.ts +30 -0
- package/src/claude/adapter.ts +336 -0
- package/src/claude/constants.ts +40 -0
- package/src/claude/environment.ts +54 -0
- package/src/claude/json-rpc.ts +50 -0
- package/src/claude/mcp-server.ts +144 -0
- package/src/claude/server.ts +37 -0
- package/src/claude/status-file.ts +66 -0
- package/src/claude/validation.ts +28 -0
- package/src/claude/wake.ts +215 -0
- package/src/claude/worker-gate-hook.ts +26 -0
- package/src/claude/worker-gate.ts +260 -0
- package/src/configured-task-core.ts +76 -0
- package/src/extension.ts +51 -0
- package/src/in-memory-task-relay.ts +115 -0
- package/src/index.ts +35 -0
- package/src/task-core.ts +925 -0
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +292 -0
- package/src/task-protocol.ts +243 -0
- package/src/task-relay-conformance.ts +33 -0
- package/src/task-store.ts +342 -0
- package/src/task-work-scope.ts +98 -0
- package/src/task-worker-mode.ts +9 -0
- package/src/volatile-task-session.ts +310 -0
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-local-auth.ts +20 -0
- package/src/wolfpack-task-relay.ts +39 -0
- package/tsconfig.json +30 -0
package/src/task-core.ts
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_RELAY_PAYLOAD_BYTES,
|
|
3
|
+
ORIGIN_CANCELLATION_OPERATION,
|
|
4
|
+
PARENT_ACKNOWLEDGMENT_OPERATION,
|
|
5
|
+
TASK_EVENT_TYPES,
|
|
6
|
+
TASK_PROTOCOL_VERSION,
|
|
7
|
+
TERMINAL_INTENT_OPERATION,
|
|
8
|
+
TaskDeliveryEvidenceState,
|
|
9
|
+
TaskDeliveryStage,
|
|
10
|
+
TaskEnvelopeKind,
|
|
11
|
+
TaskOutboxDeliveryError,
|
|
12
|
+
TaskProtocolError,
|
|
13
|
+
isBlockedOutboxDeliveryCode,
|
|
14
|
+
taskEventTypes,
|
|
15
|
+
} from "./task-protocol";
|
|
16
|
+
import type {
|
|
17
|
+
RelayDelivery,
|
|
18
|
+
RelayEnvelope,
|
|
19
|
+
TaskEndpoint,
|
|
20
|
+
TaskEvent,
|
|
21
|
+
TaskIntent,
|
|
22
|
+
TaskRecord,
|
|
23
|
+
TaskRelay,
|
|
24
|
+
TaskSnapshot,
|
|
25
|
+
} from "./task-protocol";
|
|
26
|
+
import type { TaskStore, OutboxRecord, OutboxQuarantineRecord } from "./task-store";
|
|
27
|
+
import { SYSTEM_TASK_TIMERS, TASK_WORK_LIMITS, capacityRetryAt, createTaskWorkScope, deferredTaskDelivery } from "./task-work-scope";
|
|
28
|
+
import type { TaskShipment, TaskTimers, TaskWorkScope } from "./task-work-scope";
|
|
29
|
+
|
|
30
|
+
const RECEIVE_PAGE_SIZE = TASK_WORK_LIMITS.pageItems;
|
|
31
|
+
const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
|
|
32
|
+
const RECEIVED_INTENT_OPERATION = "received_intent";
|
|
33
|
+
const TERMINAL_STATUSES: ReadonlySet<TaskRecord["status"]> = new Set(Object.values(TASK_EVENT_TYPES).flatMap(type => type.status ?? []));
|
|
34
|
+
const TERMINAL_EVENTS = taskEventTypes(type => type.terminal);
|
|
35
|
+
const CONVERSATIONAL_EVENTS = taskEventTypes(type => type.conversational);
|
|
36
|
+
const CANONICAL_EVENTS = taskEventTypes(() => true);
|
|
37
|
+
const RECEIVER_INTENT_TYPES = taskEventTypes(type => type.intent);
|
|
38
|
+
const IGNORE_OUTBOX_FAILURES: ReadonlySet<string> = new Set();
|
|
39
|
+
/** The blocked-send code that records a target loss, the precondition of a reassignment. */
|
|
40
|
+
const TARGET_LOSS_CODE = "TARGET_NOT_REGISTERED";
|
|
41
|
+
const REASSIGNED_EVENT = "task.reassigned";
|
|
42
|
+
|
|
43
|
+
export interface TaskCoreOptions {
|
|
44
|
+
readonly endpoint: TaskEndpoint;
|
|
45
|
+
readonly relay: TaskRelay;
|
|
46
|
+
readonly store: TaskStore;
|
|
47
|
+
readonly clock?: { readonly now: () => number };
|
|
48
|
+
/** Delivery budget time (deadline, slices, cooldowns); defaults to system time. Separate from `clock`, which stamps task records. */
|
|
49
|
+
readonly timers?: TaskTimers;
|
|
50
|
+
readonly ids?: () => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateTaskInput {
|
|
54
|
+
readonly target: TaskEndpoint;
|
|
55
|
+
readonly task: string;
|
|
56
|
+
readonly timeoutMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SubmitIntentInput {
|
|
60
|
+
readonly taskId: string;
|
|
61
|
+
readonly type: TaskIntent["type"];
|
|
62
|
+
readonly payload: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SubmitIntentOutcome =
|
|
66
|
+
| { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
|
|
67
|
+
| { readonly authority: "receiver" };
|
|
68
|
+
|
|
69
|
+
/** Local `wake_accepted` evidence is persisted before Pi wake emission; flush delivers it once the wake is observed. */
|
|
70
|
+
export interface WakeRequest {
|
|
71
|
+
flush(signal?: AbortSignal): Promise<void>;
|
|
72
|
+
/** Withdraws the never-sent record when Pi did not accept the wake; call synchronously after the wake attempt. */
|
|
73
|
+
discard(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One presentable inbox item, merged by store arrival order. Local items are origin-canonicalized events that never travel through the relay to the origin. */
|
|
77
|
+
export type TaskInboxItem =
|
|
78
|
+
| { readonly kind: "relay"; readonly delivery: RelayDelivery; readonly order: number }
|
|
79
|
+
| { readonly kind: "local"; readonly event: TaskEvent; readonly order: number };
|
|
80
|
+
|
|
81
|
+
export interface TaskCore {
|
|
82
|
+
readonly endpoint: TaskEndpoint;
|
|
83
|
+
connect(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
84
|
+
createTask(input: CreateTaskInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string }>;
|
|
85
|
+
getTask(taskId: string): TaskSnapshot | undefined;
|
|
86
|
+
listTasks(): readonly TaskSnapshot[];
|
|
87
|
+
/** True while any task is active or the outbox holds pending envelopes; drives the background poll cadence. */
|
|
88
|
+
hasPendingWork(): boolean;
|
|
89
|
+
/** Synchronous notification after each committed store change; returns the unsubscribe function. */
|
|
90
|
+
onChange(listener: () => void): () => void;
|
|
91
|
+
/** Correlate a receiver conversation with a locally recorded outbound intent, never session history or payload author claims. */
|
|
92
|
+
isSelfAuthoredConversation(event: TaskEvent): boolean;
|
|
93
|
+
/** Offers pending mail with bounded old-work retry; in a phased pass this is the explicit final retry phase. */
|
|
94
|
+
flushOutbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
95
|
+
receive(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly TaskInboxItem[]>;
|
|
96
|
+
/** Intake phase of a phased pass: persists and canonicalizes without sending or ACKing; required fan-out joins `work.shipments`. */
|
|
97
|
+
intakeInbox(signal?: AbortSignal, work?: TaskWorkScope): Promise<readonly TaskInboxItem[]>;
|
|
98
|
+
/** Ship phase of a phased pass: sends `work.shipments` in persist order and records each outcome; only abort throws. */
|
|
99
|
+
shipRequired(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
100
|
+
/** Acknowledge-phase repair: retries staged raw ACKs, then reports local deferral of a raw intent still pending. */
|
|
101
|
+
repairAcknowledgements(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
102
|
+
/** Retire a local item after its Pi insertion and wake evidence exist; no relay ACK or receipt. */
|
|
103
|
+
completeLocalPresentation(eventId: string): void;
|
|
104
|
+
acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
105
|
+
submitIntent(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
106
|
+
submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal, work?: TaskWorkScope): Promise<SubmitIntentOutcome>;
|
|
107
|
+
recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
108
|
+
recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }, work?: TaskWorkScope): WakeRequest;
|
|
109
|
+
evaluateTimeouts(signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
110
|
+
acknowledgeParent(taskId: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Origin-only: after a recorded TARGET_NOT_REGISTERED loss of the current target, resolves the lost
|
|
113
|
+
* target's successor and sends it a `task.reassigned` assignment carrying the task history.
|
|
114
|
+
*/
|
|
115
|
+
reassignTask?(taskId: string, signal?: AbortSignal, work?: TaskWorkScope): Promise<{ readonly taskId: string; readonly target: TaskEndpoint }>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type ReceivedEnvelope =
|
|
119
|
+
| { readonly kind: "assignment"; readonly task: Omit<TaskRecord, "events">; readonly event: TaskEvent; readonly history: readonly TaskEvent[] }
|
|
120
|
+
| { readonly kind: "intent"; readonly task: TaskRecord; readonly intent: TaskIntent }
|
|
121
|
+
| { readonly kind: "canonical_event"; readonly task: TaskRecord; readonly event: TaskEvent };
|
|
122
|
+
|
|
123
|
+
export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
124
|
+
const clock = options.clock ?? { now: (): number => Date.now() };
|
|
125
|
+
const timers = options.timers ?? SYSTEM_TASK_TIMERS;
|
|
126
|
+
const workScope = (signal?: AbortSignal, mode?: "background" | "foreground" | "phased") => createTaskWorkScope(signal, mode, timers);
|
|
127
|
+
const ids = options.ids ?? (() => crypto.randomUUID());
|
|
128
|
+
const flush = createOutboxFlusher(options);
|
|
129
|
+
const acknowledgeWithSignal = async (cursor: string, signal: AbortSignal, work: TaskWorkScope): Promise<void> => {
|
|
130
|
+
if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
131
|
+
const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
|
|
132
|
+
if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
|
|
133
|
+
const retryAt = options.store.relayAcknowledgementRetryAt(options.endpoint, cursor);
|
|
134
|
+
if (retryAt > timers.now() || !work.claimAcknowledgement(cursor)) throw deferredTaskDelivery({ cursor, retryAfterMs: Math.max(0, retryAt - timers.now()) });
|
|
135
|
+
options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor));
|
|
136
|
+
try {
|
|
137
|
+
await waitForRelayOperation(options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal), signal);
|
|
138
|
+
throwIfAborted(signal);
|
|
139
|
+
options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (signal.aborted) throw error;
|
|
142
|
+
const retryAt = capacityRetryAt(error, timers.now());
|
|
143
|
+
if (retryAt !== undefined) options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor, retryAt));
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const acknowledgeRelayDelivery = (cursor: string, signal?: AbortSignal, work = workScope(signal)): Promise<void> => acknowledgeWithSignal(cursor, work.signal, work);
|
|
148
|
+
const retryRequestedAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
|
|
149
|
+
for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint, TASK_WORK_LIMITS.acknowledgements, timers.now())) {
|
|
150
|
+
if (!work.canAcknowledge() || !work.canRepairAcknowledgement()) break;
|
|
151
|
+
if (!work.attemptedAcknowledgements.has(cursor) && options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeWithSignal(cursor, work.backgroundSignal(), work);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** Sends required envelopes now, or defers them to the ship phase of a phased pass. */
|
|
155
|
+
const ship = async (signal: AbortSignal, envelopeIds: readonly string[], work: TaskWorkScope, verify?: () => void): Promise<void> => {
|
|
156
|
+
if (work.phased) { work.shipments.push({ envelopeIds, verify, shipped: false }); return; }
|
|
157
|
+
try {
|
|
158
|
+
if (envelopeIds.length > 0) await flush(signal, new Set(envelopeIds), work);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
verify?.();
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
verify?.();
|
|
164
|
+
};
|
|
165
|
+
/** Ship phase: sends each shipment in persist order and records its outcome; only abort throws. */
|
|
166
|
+
const shipAll = async (shipments: readonly TaskShipment[], work: TaskWorkScope): Promise<void> => {
|
|
167
|
+
for (const shipment of shipments) {
|
|
168
|
+
if (shipment.shipped) continue;
|
|
169
|
+
let failure: unknown;
|
|
170
|
+
try { if (shipment.envelopeIds.length > 0) await flush(work.signal, new Set(shipment.envelopeIds), work, false); } catch (error) {
|
|
171
|
+
if (work.signal.aborted) throw error;
|
|
172
|
+
failure = error;
|
|
173
|
+
}
|
|
174
|
+
// A blocked-delivery error replaces the send failure, as for an immediate send.
|
|
175
|
+
try { shipment.verify?.(); } catch (error) { failure = error; }
|
|
176
|
+
shipment.shipped = true;
|
|
177
|
+
shipment.failure = failure;
|
|
178
|
+
if (failure === undefined) shipment.accepted?.();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
/** Acknowledge-phase repair of staged raw ACKs; a raw intent still pending afterwards reports local deferral. */
|
|
182
|
+
const repairAcknowledgements = async (work: TaskWorkScope): Promise<void> => {
|
|
183
|
+
await retryRequestedAcknowledgements(work);
|
|
184
|
+
const pendingRaw = options.store.pendingRelayDeliveries(options.endpoint).find(pending => options.store.inboxEnvelope(pending.envelopeId)?.kind === TaskEnvelopeKind.intent);
|
|
185
|
+
if (pendingRaw) throw deferredTaskDelivery({ cursor: pendingRaw.cursor,
|
|
186
|
+
retryAfterMs: Math.max(0, options.store.relayAcknowledgementRetryAt(options.endpoint, pendingRaw.cursor) - timers.now()) });
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Intake phase: re-drives tracked, unacknowledged relay deliveries from the local inbox, then fetches
|
|
190
|
+
* after the intake cursor while the pending set has room. It persists and canonicalizes without sending:
|
|
191
|
+
* each raw intent's required fan-out becomes a shipment whose acceptance stages that intent's ACK.
|
|
192
|
+
*/
|
|
193
|
+
const intake = async (signal: AbortSignal, shipments: TaskShipment[]): Promise<readonly TaskInboxItem[]> => {
|
|
194
|
+
const visibleDeliveries: RelayDelivery[] = [];
|
|
195
|
+
const settle = (delivery: RelayDelivery, received: ReceivedEnvelope, persistedEnvelopeIds: readonly string[]): void => {
|
|
196
|
+
if (received.kind !== "intent") { visibleDeliveries.push(delivery); return; }
|
|
197
|
+
// Accepted fan-out authorizes the ACK; the checkpoint owns the staged request.
|
|
198
|
+
shipments.push({ envelopeIds: persistedEnvelopeIds, shipped: false, accepted: () => options.store.transaction(() => options.store.requestRelayAcknowledgement(
|
|
199
|
+
options.endpoint, delivery.cursor, options.store.relayAcknowledgementRetryAt(options.endpoint, delivery.cursor))) });
|
|
200
|
+
};
|
|
201
|
+
// A staged ACK request already proves accepted fanout; re-drive the rest.
|
|
202
|
+
const staged = new Set(options.store.requestedRelayAcknowledgements(options.endpoint, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER));
|
|
203
|
+
for (const pending of options.store.pendingRelayDeliveries(options.endpoint)) {
|
|
204
|
+
if (staged.has(pending.cursor)) continue;
|
|
205
|
+
const envelope = options.store.inboxEnvelope(pending.envelopeId);
|
|
206
|
+
if (envelope === undefined) throw new Error("pending relay delivery was not persisted");
|
|
207
|
+
const received = validateReceivedEnvelope(options, envelope);
|
|
208
|
+
const persistedEnvelopeIds = received.kind === "intent" ? options.store.transaction(() => persistReceivedEnvelope(options, clock.now, ids, received, envelope.envelopeId)) : [];
|
|
209
|
+
settle({ cursor: pending.cursor, envelope }, received, persistedEnvelopeIds);
|
|
210
|
+
}
|
|
211
|
+
let cursor = options.store.relayIntakeCursor(options.endpoint);
|
|
212
|
+
for (let room = TASK_WORK_LIMITS.pendingDeliveries - options.store.pendingRelayDeliveries(options.endpoint).length; room > 0;
|
|
213
|
+
room = TASK_WORK_LIMITS.pendingDeliveries - options.store.pendingRelayDeliveries(options.endpoint).length) {
|
|
214
|
+
const limit = Math.min(RECEIVE_PAGE_SIZE, room);
|
|
215
|
+
const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit }, signal);
|
|
216
|
+
throwIfAborted(signal);
|
|
217
|
+
if (!isInboxPage(page) || page.deliveries.length > limit) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
|
|
218
|
+
let previous = BigInt(cursor);
|
|
219
|
+
for (const delivery of page.deliveries) {
|
|
220
|
+
if (!isDelivery(delivery) || !sameEndpoint(delivery.envelope.target, options.endpoint) || BigInt(delivery.cursor) <= previous) throw new TaskProtocolError("INVALID_DELIVERY", "relay returned an invalid or unordered delivery");
|
|
221
|
+
previous = BigInt(delivery.cursor);
|
|
222
|
+
}
|
|
223
|
+
if (page.nextCursor !== (page.deliveries.at(-1)?.cursor ?? cursor) || (!page.deliveries.length && page.hasMore)) throw new TaskProtocolError("INVALID_INBOX", "relay page cursor does not describe its deliveries");
|
|
224
|
+
for (const delivery of page.deliveries) {
|
|
225
|
+
const received = validateReceivedEnvelope(options, delivery.envelope);
|
|
226
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
227
|
+
options.store.transaction(() => {
|
|
228
|
+
// Track only with persistence: the intake cursor never passes an unpersisted delivery,
|
|
229
|
+
// so an abort or a rejected envelope leaves the suffix to be fetched again.
|
|
230
|
+
options.store.trackRelayDeliveries(options.endpoint, [{ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }]);
|
|
231
|
+
if (!options.store.persistInbox(delivery.envelope, delivery.cursor) && received.kind !== "intent") return;
|
|
232
|
+
persistedEnvelopeIds = persistReceivedEnvelope(options, clock.now, ids, received, delivery.envelope.envelopeId);
|
|
233
|
+
});
|
|
234
|
+
settle(delivery, received, persistedEnvelopeIds);
|
|
235
|
+
}
|
|
236
|
+
cursor = page.nextCursor;
|
|
237
|
+
if (!page.hasMore) break;
|
|
238
|
+
}
|
|
239
|
+
return inboxItems(options.store, visibleDeliveries);
|
|
240
|
+
};
|
|
241
|
+
const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal, work = workScope(signal, "foreground")): Promise<SubmitIntentOutcome> => {
|
|
242
|
+
signal = work.signal;
|
|
243
|
+
if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
|
|
244
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
245
|
+
let outcome: SubmitIntentOutcome | undefined;
|
|
246
|
+
let receiverTerminal = false;
|
|
247
|
+
let originCancellation = false;
|
|
248
|
+
const evidence = deliveryEvidence(input);
|
|
249
|
+
options.store.transaction(() => {
|
|
250
|
+
const task = requiredTask(options.store, input.taskId);
|
|
251
|
+
receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
|
|
252
|
+
originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
|
|
253
|
+
const persisted = persistIntent(options, clock.now, ids, task, input, evidence === undefined ? undefined : deliveryEvidenceOperation(evidence));
|
|
254
|
+
persistedEnvelopeIds = persisted.envelopeIds;
|
|
255
|
+
outcome = persisted.outcome;
|
|
256
|
+
});
|
|
257
|
+
if (evidence !== undefined) {
|
|
258
|
+
await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
|
|
259
|
+
} else {
|
|
260
|
+
try {
|
|
261
|
+
await flush(signal, new Set(persistedEnvelopeIds), work);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
267
|
+
}
|
|
268
|
+
if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
|
|
269
|
+
if (outcome === undefined) throw new TaskProtocolError("INVALID_INTENT", "task intent outcome was not persisted");
|
|
270
|
+
return outcome;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
endpoint: options.endpoint,
|
|
275
|
+
async connect(signal, work = workScope(signal)) {
|
|
276
|
+
signal = work.signal;
|
|
277
|
+
const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
|
|
278
|
+
if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
|
|
279
|
+
throwIfAborted(signal);
|
|
280
|
+
// A phased pass repairs in its acknowledge phase, after intake.
|
|
281
|
+
if (!work.phased) await retryRequestedAcknowledgements(work);
|
|
282
|
+
},
|
|
283
|
+
async createTask(input, signal, work = workScope(signal, "foreground")) {
|
|
284
|
+
signal = work.signal;
|
|
285
|
+
if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
|
|
286
|
+
const target = await options.relay.resolve({ relay: input.target.relay, reference: input.target.id }, signal);
|
|
287
|
+
throwIfAborted(signal);
|
|
288
|
+
if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
|
|
289
|
+
const now = clock.now();
|
|
290
|
+
const taskId = ids();
|
|
291
|
+
const task: Omit<TaskRecord, "events"> = {
|
|
292
|
+
taskId, protocolVersion: TASK_PROTOCOL_VERSION, origin: options.endpoint, target, task: input.task,
|
|
293
|
+
createdAt: now, expiresAt: now + input.timeoutMs, status: "active",
|
|
294
|
+
};
|
|
295
|
+
const created = event(task, ids(), "task.created", "1", options.endpoint, target, now, { task: input.task });
|
|
296
|
+
const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created }, now);
|
|
297
|
+
options.store.transaction(() => {
|
|
298
|
+
options.store.putTask(task);
|
|
299
|
+
options.store.appendEvent(created);
|
|
300
|
+
options.store.putOutbox(assignment);
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
await flush(signal, new Set([assignment.envelopeId]), work);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw operationError(error, taskId);
|
|
306
|
+
}
|
|
307
|
+
return { taskId };
|
|
308
|
+
},
|
|
309
|
+
async reassignTask(taskId, signal, work = workScope(signal, "foreground")) {
|
|
310
|
+
signal = work.signal;
|
|
311
|
+
const lost = lostTarget(options, taskId).target;
|
|
312
|
+
const target = await options.relay.resolve({ relay: lost.relay, reference: lost.id }, signal);
|
|
313
|
+
throwIfAborted(signal);
|
|
314
|
+
if (!isEndpoint(target)) throw new TaskProtocolError("INVALID_TARGET", "relay resolved an invalid task endpoint");
|
|
315
|
+
if (sameEndpoint(target, lost)) throw reassignmentRefused(taskId, "the lost target resolved to itself");
|
|
316
|
+
const assignmentId = ids();
|
|
317
|
+
options.store.transaction(() => {
|
|
318
|
+
// Re-checked here: a concurrent reassignment already moved the target, so one loss allows one reassignment.
|
|
319
|
+
const task = lostTarget(options, taskId);
|
|
320
|
+
if (!sameEndpoint(task.target, lost)) throw reassignmentRefused(taskId, "the task target changed during resolution");
|
|
321
|
+
const now = clock.now();
|
|
322
|
+
const record: Omit<TaskRecord, "events"> = {
|
|
323
|
+
taskId, protocolVersion: task.protocolVersion, origin: task.origin, target, task: task.task,
|
|
324
|
+
createdAt: task.createdAt, expiresAt: task.expiresAt, status: task.status,
|
|
325
|
+
};
|
|
326
|
+
const reassigned = event(record, ids(), REASSIGNED_EVENT, String(task.events.length + 1), options.endpoint, target, now, { task: task.task, previousTarget: lost });
|
|
327
|
+
// Origin-local delivery evidence never travels to a target.
|
|
328
|
+
const history = task.events.filter(candidate => TASK_EVENT_TYPES[candidate.type]?.evidence !== true);
|
|
329
|
+
options.store.setTarget(taskId, target);
|
|
330
|
+
options.store.appendEvent(reassigned);
|
|
331
|
+
options.store.putOutbox(envelope(assignmentId, options.endpoint, target, taskId, TaskEnvelopeKind.assignment, reassignmentPayload(record, reassigned, history), now));
|
|
332
|
+
if (!sameEndpoint(target, options.endpoint)) options.store.enqueuePresentation({ taskId, eventId: reassigned.eventId });
|
|
333
|
+
});
|
|
334
|
+
try {
|
|
335
|
+
await flush(signal, new Set([assignmentId]), work);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw operationError(error, taskId);
|
|
338
|
+
}
|
|
339
|
+
return { taskId, target };
|
|
340
|
+
},
|
|
341
|
+
getTask(taskId) { return options.store.getTask(taskId); },
|
|
342
|
+
listTasks() { return options.store.listTasks(); },
|
|
343
|
+
onChange(listener) { return options.store.onChange(listener); },
|
|
344
|
+
hasPendingWork() { return options.store.hasPendingOutbox() || options.store.hasActiveTasks(); },
|
|
345
|
+
isSelfAuthoredConversation(event) {
|
|
346
|
+
if (!CONVERSATIONAL_EVENTS.has(event.type) || typeof event.payload.intentId !== "string") return false;
|
|
347
|
+
const task = options.store.getTask(event.taskId);
|
|
348
|
+
if (!task || !sameEndpoint(options.endpoint, task.target) || sameEndpoint(options.endpoint, task.origin)
|
|
349
|
+
|| !sameEndpoint(event.source, task.origin) || !sameEndpoint(event.target, task.target)) return false;
|
|
350
|
+
const localEnvelope = options.store.getIntentEnvelope(event.payload.intentId);
|
|
351
|
+
if (!localEnvelope || localEnvelope.kind !== TaskEnvelopeKind.intent || localEnvelope.taskId !== task.taskId
|
|
352
|
+
|| !sameEndpoint(localEnvelope.source, options.endpoint) || !sameEndpoint(localEnvelope.target, task.origin)) return false;
|
|
353
|
+
const intent = parsePayload(localEnvelope);
|
|
354
|
+
return isTaskIntent(intent) && intent.taskId === event.taskId && intent.type === event.type && intent.intentId === event.payload.intentId;
|
|
355
|
+
},
|
|
356
|
+
async flushOutbox(signal, work = workScope(signal)) { await flush(work.signal, undefined, work, true); },
|
|
357
|
+
async receive(signal, work = workScope(signal)) {
|
|
358
|
+
// The phases of a pass without presentation; the first fan-out or repair failure surfaces after them.
|
|
359
|
+
const shipments: TaskShipment[] = [];
|
|
360
|
+
let items: readonly TaskInboxItem[], failure: unknown;
|
|
361
|
+
try {
|
|
362
|
+
items = await intake(work.signal, shipments);
|
|
363
|
+
await shipAll(shipments, work);
|
|
364
|
+
failure = shipments.find(shipment => shipment.failure !== undefined)?.failure;
|
|
365
|
+
try { await repairAcknowledgements(work); } catch (error) {
|
|
366
|
+
if (work.signal.aborted) throw error;
|
|
367
|
+
failure ??= error;
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
// A phased driver owns the later retry share; direct receive retains its own.
|
|
371
|
+
if (!work.phased && !work.signal.aborted) await flush(work.signal, IGNORE_OUTBOX_FAILURES, work, true);
|
|
372
|
+
}
|
|
373
|
+
if (failure !== undefined) throw failure;
|
|
374
|
+
return items;
|
|
375
|
+
},
|
|
376
|
+
async intakeInbox(signal, work = workScope(signal, "phased")) { return intake(work.signal, work.shipments); },
|
|
377
|
+
async shipRequired(signal, work = workScope(signal, "phased")) { await shipAll(work.shipments, work); },
|
|
378
|
+
async repairAcknowledgements(signal, work = workScope(signal)) { await repairAcknowledgements(work); },
|
|
379
|
+
acknowledgeRelayDelivery,
|
|
380
|
+
completeLocalPresentation(eventId) { options.store.completePresentation(eventId); },
|
|
381
|
+
async submitIntent(input, signal, work) { await submitIntentWithOutcome(input, signal, work); },
|
|
382
|
+
submitIntentWithOutcome,
|
|
383
|
+
async recordInsertion(input, signal, work = workScope(signal)) {
|
|
384
|
+
signal = work.signal;
|
|
385
|
+
const task = requiredTask(options.store, input.taskId);
|
|
386
|
+
const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
|
|
387
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
388
|
+
options.store.transaction(() => {
|
|
389
|
+
options.store.putInsertionReceipt(input.taskId, input.eventId);
|
|
390
|
+
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
|
|
391
|
+
taskId: input.taskId,
|
|
392
|
+
type: "task.delivery_receipt",
|
|
393
|
+
payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
|
|
394
|
+
}, deliveryEvidenceOperation(evidence)).envelopeIds;
|
|
395
|
+
});
|
|
396
|
+
await ship(signal, persistedEnvelopeIds, work, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, persistedEnvelopeIds, evidence));
|
|
397
|
+
},
|
|
398
|
+
recordWakeRequest(input, work) {
|
|
399
|
+
const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed };
|
|
400
|
+
const operation = deliveryEvidenceOperation(evidence);
|
|
401
|
+
const persist = () => options.store.transaction(() => {
|
|
402
|
+
options.store.putInsertionReceipt(input.taskId, input.eventId);
|
|
403
|
+
return persistIntent(options, clock.now, ids, requiredTask(options.store, input.taskId), {
|
|
404
|
+
taskId: input.taskId,
|
|
405
|
+
type: "task.delivery_receipt",
|
|
406
|
+
payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
|
|
407
|
+
}, operation);
|
|
408
|
+
});
|
|
409
|
+
// An origin canonicalizes evidence straight into its task log, which cannot be withdrawn,
|
|
410
|
+
// so it records on flush; a receiver writes its outbound record before the wake.
|
|
411
|
+
let persisted = sameEndpoint(options.endpoint, requiredTask(options.store, input.taskId).origin) ? undefined : persist();
|
|
412
|
+
return {
|
|
413
|
+
flush: async (signal?: AbortSignal): Promise<void> => {
|
|
414
|
+
const { envelopeIds } = persisted ??= persist();
|
|
415
|
+
const activeWork = work ?? workScope(signal);
|
|
416
|
+
await ship(activeWork.signal, envelopeIds, activeWork, () => throwIfDeliveryEvidenceBlocked(options.store, input.taskId, envelopeIds, evidence));
|
|
417
|
+
},
|
|
418
|
+
discard: () => {
|
|
419
|
+
// No await separates the write from this call, so the envelope was never offered to the relay.
|
|
420
|
+
if (persisted?.created) options.store.discardUnsentOperation(input.taskId, operation);
|
|
421
|
+
persisted = undefined;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
async evaluateTimeouts(signal, work = workScope(signal)) {
|
|
426
|
+
signal = work.signal;
|
|
427
|
+
const persistedEnvelopeIds: string[] = [];
|
|
428
|
+
for (const candidate of options.store.listTasks()) {
|
|
429
|
+
if (!sameEndpoint(candidate.origin, options.endpoint) || TERMINAL_STATUSES.has(candidate.status) || candidate.expiresAt > clock.now()) continue;
|
|
430
|
+
options.store.transaction(() => {
|
|
431
|
+
const task = options.store.getTask(candidate.taskId);
|
|
432
|
+
if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
|
|
433
|
+
persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (persistedEnvelopeIds.length > 0) await ship(signal, persistedEnvelopeIds, work);
|
|
437
|
+
},
|
|
438
|
+
async acknowledgeParent(taskId, signal, work = workScope(signal)) {
|
|
439
|
+
signal = work.signal;
|
|
440
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
441
|
+
options.store.transaction(() => {
|
|
442
|
+
const task = requiredTask(options.store, taskId);
|
|
443
|
+
if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
|
|
444
|
+
if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
|
|
445
|
+
persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
|
|
446
|
+
});
|
|
447
|
+
await flush(signal, new Set(persistedEnvelopeIds), work);
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function validateReceivedEnvelope(options: TaskCoreOptions, relayEnvelope: unknown): ReceivedEnvelope {
|
|
453
|
+
if (!isEnvelope(relayEnvelope)) throw new TaskProtocolError("INVALID_ENVELOPE", "relay envelope is malformed");
|
|
454
|
+
if (relayEnvelope.protocolVersion !== TASK_PROTOCOL_VERSION) throw new TaskProtocolError("INCOMPATIBLE_PROTOCOL", "received an incompatible task envelope");
|
|
455
|
+
if (!sameEndpoint(relayEnvelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "relay envelope targets another endpoint");
|
|
456
|
+
const payload = parsePayload(relayEnvelope);
|
|
457
|
+
if (relayEnvelope.kind === TaskEnvelopeKind.assignment) return validateAssignment(relayEnvelope, payload);
|
|
458
|
+
if (relayEnvelope.kind === TaskEnvelopeKind.intent) return validateIntent(options, relayEnvelope, payload);
|
|
459
|
+
return validateCanonicalEvent(options, relayEnvelope, payload);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validateAssignment(envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
463
|
+
if (!isRecord(payload) || !isTaskRecordInput(payload.task) || !isTaskEvent(payload.event)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment payload is malformed");
|
|
464
|
+
if (payload.event.type === REASSIGNED_EVENT) return validateReassignment(envelope, payload, payload.task, payload.event);
|
|
465
|
+
const task = payload.task;
|
|
466
|
+
const created = payload.event;
|
|
467
|
+
if (envelope.taskId !== task.taskId || envelope.taskId !== created.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
|
|
468
|
+
|| task.status !== "active" || task.expiresAt < task.createdAt || created.type !== "task.created" || created.sequence !== "1" || created.payload.task !== task.task
|
|
469
|
+
|| !sameEndpoint(envelope.source, task.origin) || !sameEndpoint(envelope.target, task.target)
|
|
470
|
+
|| !sameEndpoint(created.source, task.origin) || !sameEndpoint(created.target, task.target)) {
|
|
471
|
+
throw new TaskProtocolError("INVALID_ASSIGNMENT", "assignment envelope headers do not match its payload");
|
|
472
|
+
}
|
|
473
|
+
return { kind: "assignment", task, event: created, history: [] };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* A reassignment carries history addressed to earlier targets, so each carried event is checked
|
|
478
|
+
* against the task and its origin, not the current target. Without `historyTruncated` it starts at `task.created`.
|
|
479
|
+
*/
|
|
480
|
+
function validateReassignment(envelope: RelayEnvelope, payload: Record<string, unknown>, task: Omit<TaskRecord, "events">, reassigned: TaskEvent): ReceivedEnvelope {
|
|
481
|
+
const truncated = payload.historyTruncated === true;
|
|
482
|
+
const history = payload.history;
|
|
483
|
+
if (!Array.isArray(history) || !history.every(isTaskEvent) || (payload.historyTruncated !== undefined && !truncated)) throw new TaskProtocolError("INVALID_ASSIGNMENT", "reassignment payload is malformed");
|
|
484
|
+
let previous = 0n;
|
|
485
|
+
const carried = history.every(event => {
|
|
486
|
+
const ordered = BigInt(event.sequence) > previous;
|
|
487
|
+
previous = BigInt(event.sequence);
|
|
488
|
+
return ordered && event.taskId === task.taskId && CANONICAL_EVENTS.has(event.type) && TASK_EVENT_TYPES[event.type]?.evidence !== true && sameEndpoint(event.source, task.origin);
|
|
489
|
+
});
|
|
490
|
+
const first = history[0];
|
|
491
|
+
if (envelope.taskId !== task.taskId || envelope.taskId !== reassigned.taskId || task.protocolVersion !== TASK_PROTOCOL_VERSION
|
|
492
|
+
|| task.status !== "active" || task.expiresAt < task.createdAt || reassigned.payload.task !== task.task
|
|
493
|
+
|| !sameEndpoint(envelope.source, task.origin) || !sameEndpoint(envelope.target, task.target)
|
|
494
|
+
|| !sameEndpoint(reassigned.source, task.origin) || !sameEndpoint(reassigned.target, task.target)
|
|
495
|
+
|| !carried || previous >= BigInt(reassigned.sequence)
|
|
496
|
+
|| (!truncated && (first?.type !== "task.created" || first.sequence !== "1" || first.payload.task !== task.task))) {
|
|
497
|
+
throw new TaskProtocolError("INVALID_ASSIGNMENT", "reassignment headers, payload or history do not match");
|
|
498
|
+
}
|
|
499
|
+
return { kind: "assignment", task, event: reassigned, history };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The task, its reassignment and as much of the latest history as fits the relay payload limit. */
|
|
503
|
+
function reassignmentPayload(task: Omit<TaskRecord, "events">, reassigned: TaskEvent, history: readonly TaskEvent[]): unknown {
|
|
504
|
+
const bytes = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
505
|
+
const complete = { task, event: reassigned, history };
|
|
506
|
+
if (bytes(complete) <= MAX_RELAY_PAYLOAD_BYTES) return complete;
|
|
507
|
+
// Keep the newest suffix: each kept event costs its JSON plus a separating comma.
|
|
508
|
+
let size = bytes({ task, event: reassigned, history: [], historyTruncated: true });
|
|
509
|
+
let start = history.length;
|
|
510
|
+
while (start > 0) {
|
|
511
|
+
const next = size + bytes(history[start - 1]) + (start === history.length ? 0 : 1);
|
|
512
|
+
if (next > MAX_RELAY_PAYLOAD_BYTES) break;
|
|
513
|
+
size = next;
|
|
514
|
+
start--;
|
|
515
|
+
}
|
|
516
|
+
if (size > MAX_RELAY_PAYLOAD_BYTES) throw new TaskProtocolError("PAYLOAD_TOO_LARGE", "reassignment exceeds the relay payload limit", { retryable: false });
|
|
517
|
+
return { task, event: reassigned, history: history.slice(start), historyTruncated: true };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The origin's active task whose current target has a recorded TARGET_NOT_REGISTERED loss. */
|
|
521
|
+
function lostTarget(options: TaskCoreOptions, taskId: string): TaskSnapshot {
|
|
522
|
+
const task = requiredTask(options.store, taskId);
|
|
523
|
+
if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only the origin may reassign a task", { retryable: false });
|
|
524
|
+
if (task.status !== "active") throw reassignmentRefused(taskId, "the task is not active");
|
|
525
|
+
// Keyed on the current target: leftover envelopes to an earlier target never re-trigger.
|
|
526
|
+
const lost = options.store.quarantinedOutbox().some(record => record.errorCode === TARGET_LOSS_CODE && record.envelope.taskId === taskId && sameEndpoint(record.envelope.target, task.target));
|
|
527
|
+
if (!lost) throw reassignmentRefused(taskId, "no loss of the current target is recorded");
|
|
528
|
+
return task;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function reassignmentRefused(taskId: string, reason: string): TaskProtocolError {
|
|
532
|
+
return new TaskProtocolError("REASSIGNMENT_REFUSED", `task cannot be reassigned: ${reason}`, { retryable: false, details: { taskId } });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function validateIntent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
536
|
+
const task = requiredTask(options.store, envelope.taskId);
|
|
537
|
+
if (!sameEndpoint(task.origin, options.endpoint) || !sameEndpoint(envelope.source, task.target) || !sameEndpoint(envelope.target, task.origin)) {
|
|
538
|
+
throw new TaskProtocolError("UNAUTHORIZED_INTENT", "only the assigned receiver may submit an intent to origin");
|
|
539
|
+
}
|
|
540
|
+
if (!isTaskIntent(payload) || payload.taskId !== envelope.taskId || !RECEIVER_INTENT_TYPES.has(payload.type)) {
|
|
541
|
+
throw new TaskProtocolError("INVALID_INTENT", "intent envelope headers or payload are invalid");
|
|
542
|
+
}
|
|
543
|
+
return { kind: "intent", task, intent: payload };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function validateCanonicalEvent(options: TaskCoreOptions, envelope: RelayEnvelope, payload: unknown): ReceivedEnvelope {
|
|
547
|
+
const task = requiredTask(options.store, envelope.taskId);
|
|
548
|
+
if (!isTaskEvent(payload) || !CANONICAL_EVENTS.has(payload.type) || payload.taskId !== envelope.taskId
|
|
549
|
+
|| !sameEndpoint(envelope.source, task.origin) || (!sameEndpoint(envelope.target, task.target) && !sameEndpoint(envelope.target, task.origin))
|
|
550
|
+
|| !sameEndpoint(payload.source, task.origin) || !sameEndpoint(payload.target, task.target)) {
|
|
551
|
+
throw new TaskProtocolError("INVALID_EVENT", "canonical event envelope headers or payload are invalid");
|
|
552
|
+
}
|
|
553
|
+
if (!sameEndpoint(envelope.target, options.endpoint)) throw new TaskProtocolError("INVALID_TARGET", "canonical event targets another endpoint");
|
|
554
|
+
return { kind: "canonical_event", task, event: payload };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, ids: () => string, received: ReceivedEnvelope, envelopeId: string): readonly string[] {
|
|
558
|
+
if (received.kind === "assignment") {
|
|
559
|
+
options.store.putTask(received.task);
|
|
560
|
+
// A known task follows the reassignment; carried history is append-only and skips events already held.
|
|
561
|
+
if (received.event.type === REASSIGNED_EVENT) options.store.setTarget(received.task.taskId, received.task.target);
|
|
562
|
+
for (const carried of received.history) options.store.appendEvent(carried);
|
|
563
|
+
options.store.appendEvent(received.event);
|
|
564
|
+
return [];
|
|
565
|
+
}
|
|
566
|
+
if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type, `${RECEIVED_INTENT_OPERATION}:${envelopeId}`).envelopeIds;
|
|
567
|
+
if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
interface PersistedIntent {
|
|
572
|
+
readonly envelopeIds: readonly string[];
|
|
573
|
+
readonly outcome: SubmitIntentOutcome;
|
|
574
|
+
/** False when an existing operation reservation was reused. */
|
|
575
|
+
readonly created: boolean;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
|
|
579
|
+
if (!sameEndpoint(options.endpoint, task.origin) && !sameEndpoint(options.endpoint, task.target)) throw new TaskProtocolError("NOT_PARTICIPANT", "historical task belongs to a different endpoint", { retryable: false });
|
|
580
|
+
if (sameEndpoint(options.endpoint, task.origin)) {
|
|
581
|
+
const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
|
|
582
|
+
// The origin already recorded its own conversational event; present only peer-authored conversations for model incorporation.
|
|
583
|
+
const presentToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
|
|
584
|
+
const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, presentToOrigin);
|
|
585
|
+
return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } }, created: !canonical.reused };
|
|
586
|
+
}
|
|
587
|
+
const envelopeId = ids();
|
|
588
|
+
const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
|
|
589
|
+
const operation = TERMINAL_EVENTS.has(input.type) ? TERMINAL_INTENT_OPERATION : reservedOperation;
|
|
590
|
+
if (operation !== undefined) {
|
|
591
|
+
const reservation = options.store.reserveTaskOperation({
|
|
592
|
+
taskId: input.taskId,
|
|
593
|
+
operation,
|
|
594
|
+
logicalId: intent.intentId,
|
|
595
|
+
logicalType: intent.type,
|
|
596
|
+
envelopeIds: [envelopeId],
|
|
597
|
+
});
|
|
598
|
+
if (!reservation.created) {
|
|
599
|
+
if (operation === TERMINAL_INTENT_OPERATION && reservation.record.logicalType !== intent.type) {
|
|
600
|
+
throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
|
|
601
|
+
retryable: false,
|
|
602
|
+
details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" }, created: false };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
options.store.putIntent(intent.intentId, input.taskId, envelopeId);
|
|
609
|
+
options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
|
|
610
|
+
return { envelopeIds: [envelopeId], outcome: { authority: "receiver" }, created: true };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
interface DeliveryEvidence {
|
|
614
|
+
readonly eventId: string;
|
|
615
|
+
readonly stage: TaskDeliveryStage;
|
|
616
|
+
readonly state: TaskDeliveryEvidenceState;
|
|
617
|
+
readonly retryable?: true;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefined {
|
|
621
|
+
if (input.type !== "task.delivery_receipt" || typeof input.payload.eventId !== "string") return undefined;
|
|
622
|
+
const stage = input.payload.stage;
|
|
623
|
+
const state = input.payload.state;
|
|
624
|
+
if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
|
|
625
|
+
return { eventId: input.payload.eventId, stage, state, retryable: true };
|
|
626
|
+
}
|
|
627
|
+
if ((stage === TaskDeliveryStage.receiverRecorded || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
|
|
628
|
+
&& state === TaskDeliveryEvidenceState.confirmed) {
|
|
629
|
+
return { eventId: input.payload.eventId, stage, state };
|
|
630
|
+
}
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function deliveryEvidenceOperation(input: DeliveryEvidence): string {
|
|
635
|
+
return `${DELIVERY_EVIDENCE_OPERATION}:${input.eventId}:${input.stage}:${input.state}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface Canonicalization {
|
|
639
|
+
readonly envelopeIds: readonly string[];
|
|
640
|
+
readonly eventType: string;
|
|
641
|
+
readonly reused: boolean;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Origin-only. The origin sees its canonical event through the local presentation queue, never a relay envelope to itself. */
|
|
645
|
+
function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string, presentToOrigin = true): Canonicalization {
|
|
646
|
+
const terminal = TERMINAL_EVENTS.has(requestedType);
|
|
647
|
+
const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
|
|
648
|
+
const sequence = String(task.events.length + 1);
|
|
649
|
+
const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { ...intent.payload, intentId: intent.intentId });
|
|
650
|
+
// A self-targeted task presents through its target envelope; suppress that for its own conversations.
|
|
651
|
+
const selfTarget = sameEndpoint(task.origin, task.target);
|
|
652
|
+
// Delivery evidence is origin-local status: no fan-out to the target and no presentation.
|
|
653
|
+
const originLocal = TASK_EVENT_TYPES[requestedType]?.evidence === true;
|
|
654
|
+
const targetEnvelope = originLocal || (!presentToOrigin && selfTarget) ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
|
|
655
|
+
const persistedEnvelopeIds = targetEnvelope === undefined ? [] : [targetEnvelope.envelopeId];
|
|
656
|
+
if (operation !== undefined) {
|
|
657
|
+
const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
|
|
658
|
+
if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
|
|
659
|
+
}
|
|
660
|
+
options.store.appendEvent(canonical);
|
|
661
|
+
if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
|
|
662
|
+
if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
|
|
663
|
+
if (presentToOrigin && !selfTarget && !originLocal) options.store.enqueuePresentation({ taskId: task.taskId, eventId: canonical.eventId });
|
|
664
|
+
return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function inboxItems(store: TaskStore, deliveries: readonly RelayDelivery[]): readonly TaskInboxItem[] {
|
|
668
|
+
const items: TaskInboxItem[] = deliveries.map((delivery) => {
|
|
669
|
+
const order = store.inboxOrder(delivery.envelope.envelopeId);
|
|
670
|
+
if (order === undefined) throw new Error("visible relay delivery was not persisted");
|
|
671
|
+
return { kind: "relay", delivery, order };
|
|
672
|
+
});
|
|
673
|
+
for (const pending of store.pendingPresentations()) {
|
|
674
|
+
const event = store.getTask(pending.taskId)?.events.find((candidate) => candidate.eventId === pending.eventId);
|
|
675
|
+
if (event === undefined) throw new Error("local presentation event is not in the task log");
|
|
676
|
+
items.push({ kind: "local", event, order: pending.order });
|
|
677
|
+
}
|
|
678
|
+
return items.sort((left, right) => left.order - right.order);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Sends the reported envelopes, then at most once per scope offers bounded old-work retry when `retry` (default: outside a phased pass). */
|
|
682
|
+
type OutboxFlusher = (signal: AbortSignal | undefined, reportedEnvelopeIds: ReadonlySet<string> | undefined, work: TaskWorkScope, retry?: boolean) => Promise<void>;
|
|
683
|
+
|
|
684
|
+
function createOutboxFlusher(options: TaskCoreOptions): OutboxFlusher {
|
|
685
|
+
const timers = options.timers ?? SYSTEM_TASK_TIMERS;
|
|
686
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
687
|
+
const send = async (record: OutboxRecord, retry: boolean, work: TaskWorkScope): Promise<void> => {
|
|
688
|
+
const id = record.envelope.envelopeId;
|
|
689
|
+
// Per-target FIFO: older pending mail to the same target goes first. Wait out an
|
|
690
|
+
// older send already in flight instead of racing it; otherwise defer behind it.
|
|
691
|
+
for (let ahead = options.store.pendingOutboxAhead(id); ahead.length; ahead = options.store.pendingOutboxAhead(id)) {
|
|
692
|
+
const older = ahead.map(olderId => inFlight.get(olderId)).find(Boolean);
|
|
693
|
+
if (!older) throw deferredTaskDelivery({ envelopeId: id, blockedBy: ahead[0], retryAfterMs: Math.max(0, options.store.outboxRetryAt(ahead[0]!) - timers.now()) });
|
|
694
|
+
await waitForRelayOperation(older.catch(() => undefined), work.signal);
|
|
695
|
+
}
|
|
696
|
+
const joined = inFlight.get(id);
|
|
697
|
+
if (joined) { await waitForRelayOperation(joined, work.signal); return; }
|
|
698
|
+
const retryAt = options.store.outboxRetryAt(id);
|
|
699
|
+
if (retryAt > timers.now() || !work.claimSend(id, retry)) throw deferredTaskDelivery({ envelopeId: id, retryAfterMs: Math.max(0, retryAt - timers.now()) });
|
|
700
|
+
const signal = retry ? work.backgroundSignal() : work.signal;
|
|
701
|
+
const delivery = (async (): Promise<void> => {
|
|
702
|
+
try {
|
|
703
|
+
await waitForRelayOperation(options.relay.send(record.envelope, signal), signal);
|
|
704
|
+
throwIfAborted(signal);
|
|
705
|
+
options.store.transaction(() => options.store.markOutboxAccepted(id));
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (signal.aborted) throw error;
|
|
708
|
+
const retryAt = capacityRetryAt(error, timers.now());
|
|
709
|
+
if (retryAt !== undefined) {
|
|
710
|
+
options.store.postponeOutbox(id, retryAt);
|
|
711
|
+
// A large foreground refusal does not prove smaller old mail cannot fit.
|
|
712
|
+
if (retry) work.blockedRetryTargets.add(JSON.stringify(record.envelope.target));
|
|
713
|
+
}
|
|
714
|
+
if (error instanceof TaskProtocolError && isBlockedOutboxDeliveryCode(error.code) && !error.retryable) options.store.transaction(() => {
|
|
715
|
+
options.store.quarantineOutbox(id, { errorCode: error.code, reason: error.message, details: error.details ?? {}, quarantinedAt: timers.now() });
|
|
716
|
+
});
|
|
717
|
+
throw error;
|
|
718
|
+
}
|
|
719
|
+
})();
|
|
720
|
+
inFlight.set(id, delivery);
|
|
721
|
+
try { await delivery; } finally { if (inFlight.get(id) === delivery) inFlight.delete(id); }
|
|
722
|
+
};
|
|
723
|
+
return async (signal, reportedEnvelopeIds, work, retry = !work.phased) => {
|
|
724
|
+
throwIfAborted(signal);
|
|
725
|
+
let failure: unknown;
|
|
726
|
+
if (reportedEnvelopeIds) for (const id of reportedEnvelopeIds) {
|
|
727
|
+
const record = options.store.getOutboxRecord(id);
|
|
728
|
+
if (record?.state === "accepted") continue;
|
|
729
|
+
if (!record) {
|
|
730
|
+
const quarantined = options.store.getQuarantine(id);
|
|
731
|
+
failure ??= quarantined ? new TaskProtocolError(quarantined.errorCode, quarantined.reason, { retryable: false, details: quarantined.details }) : deferredTaskDelivery({ envelopeId: id });
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
// Offer older pending mail to the same target first, as bounded old work (retry
|
|
735
|
+
// budget and background window); its failure leaves this envelope deferred.
|
|
736
|
+
for (const olderId of options.store.pendingOutboxAhead(id)) {
|
|
737
|
+
const older = options.store.getOutboxRecord(olderId);
|
|
738
|
+
if (!older || inFlight.has(olderId)) continue;
|
|
739
|
+
if (work.blockedRetryTargets.has(JSON.stringify(older.envelope.target))) break;
|
|
740
|
+
try { await send(older, true, work); } catch (error) { if (work.signal.aborted) throw error; break; }
|
|
741
|
+
}
|
|
742
|
+
try { await send(record, false, work); } catch (error) {
|
|
743
|
+
if (work.signal.aborted) throw error;
|
|
744
|
+
failure ??= error;
|
|
745
|
+
if (error instanceof TaskProtocolError && error.code === "TASK_DELIVERY_DEFERRED") break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (retry && !work.retryStarted) {
|
|
749
|
+
work.retryStarted = true;
|
|
750
|
+
let attempts = 0;
|
|
751
|
+
for (let inspected = 0; inspected < TASK_WORK_LIMITS.retryInspections && attempts < TASK_WORK_LIMITS.retries; inspected++) {
|
|
752
|
+
if (!options.store.hasPendingOutbox() || !work.canRetry()) break;
|
|
753
|
+
// Advance one attempted/actually skipped ID, never an unattempted batch suffix.
|
|
754
|
+
const record = options.store.selectPendingOutbox(1, timers.now(), 1)[0];
|
|
755
|
+
if (!record) continue;
|
|
756
|
+
const id = record.envelope.envelopeId;
|
|
757
|
+
if (reportedEnvelopeIds?.has(id) || work.attemptedSends.has(id) || inFlight.has(id) || work.blockedRetryTargets.has(JSON.stringify(record.envelope.target))) continue;
|
|
758
|
+
attempts++;
|
|
759
|
+
try { await send(record, true, work); } catch (error) {
|
|
760
|
+
if (work.signal.aborted) throw error;
|
|
761
|
+
if (reportedEnvelopeIds === undefined) failure ??= error;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (failure === undefined && reportedEnvelopeIds === undefined && options.store.hasPendingOutbox()) failure = deferredTaskDelivery();
|
|
766
|
+
if (failure !== undefined) throw outboxDeliveryError(failure);
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function waitForRelayOperation(delivery: Promise<unknown>, signal: AbortSignal): Promise<void> {
|
|
771
|
+
// Observe the operation first: an abort found here must not leave its later rejection unhandled.
|
|
772
|
+
delivery.catch(() => undefined);
|
|
773
|
+
throwIfAborted(signal);
|
|
774
|
+
let abort!: () => void;
|
|
775
|
+
const cancelled = new Promise<never>((_, reject) => { abort = () => reject(new TaskProtocolError("ABORTED", "task operation aborted", { details: { mayHaveBeenDelivered: true } })); signal.addEventListener("abort", abort, { once: true }); });
|
|
776
|
+
try { await Promise.race([delivery, cancelled]); throwIfAborted(signal); }
|
|
777
|
+
finally { signal.removeEventListener("abort", abort); }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function outboxDeliveryError(error: unknown): TaskOutboxDeliveryError {
|
|
781
|
+
if (error instanceof TaskProtocolError) {
|
|
782
|
+
return new TaskOutboxDeliveryError(error.code, error.message, { retryable: error.retryable, details: error.details });
|
|
783
|
+
}
|
|
784
|
+
return new TaskOutboxDeliveryError("TASK_ERROR", error instanceof Error ? error.message : "task delivery failed");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function operationError(error: unknown, taskId: string): TaskProtocolError {
|
|
788
|
+
if (error instanceof TaskProtocolError) {
|
|
789
|
+
return new TaskProtocolError(error.code, error.message, { retryable: error.retryable, details: { ...error.details, taskId } });
|
|
790
|
+
}
|
|
791
|
+
return new TaskProtocolError("TASK_ERROR", error instanceof Error ? error.message : "task operation failed", { details: { taskId } });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function event(task: Omit<TaskRecord, "events">, eventId: string, type: string, sequence: string, source: TaskEndpoint, target: TaskEndpoint, occurredAt: number, payload: Record<string, unknown>): TaskEvent {
|
|
795
|
+
return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown, createdAt: number): RelayEnvelope {
|
|
799
|
+
return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload), createdAt: new Date(createdAt).toISOString() };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function parsePayload(envelope: RelayEnvelope): unknown {
|
|
803
|
+
try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** First of `envelopeIds`, in their order, that is quarantined and matches; indexed per id, not a quarantine scan. */
|
|
807
|
+
function firstQuarantined(store: TaskStore, envelopeIds: readonly string[], matches: (record: OutboxQuarantineRecord) => boolean = () => true): OutboxQuarantineRecord | undefined {
|
|
808
|
+
for (const id of envelopeIds) { const record = store.getQuarantine(id); if (record && matches(record)) return record; }
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: string, envelopeIds: readonly string[]): void {
|
|
813
|
+
const task = requiredTask(options.store, taskId);
|
|
814
|
+
const blocked = firstQuarantined(options.store, envelopeIds, (record) => sameEndpoint(record.envelope.target, task.target));
|
|
815
|
+
if (blocked === undefined) return;
|
|
816
|
+
throw new TaskOutboxDeliveryError(blocked.errorCode, blocked.reason, {
|
|
817
|
+
retryable: false,
|
|
818
|
+
details: { ...blocked.details, taskId, envelopeId: blocked.envelope.envelopeId, target: task.target, blockedAt: blocked.quarantinedAt },
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
|
|
823
|
+
const blocked = firstQuarantined(store, envelopeIds);
|
|
824
|
+
if (blocked === undefined) return;
|
|
825
|
+
throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
|
|
826
|
+
retryable: false,
|
|
827
|
+
details: {
|
|
828
|
+
...blocked.details,
|
|
829
|
+
taskId,
|
|
830
|
+
eventId: evidence.eventId,
|
|
831
|
+
stage: evidence.stage,
|
|
832
|
+
state: evidence.state,
|
|
833
|
+
envelopeId: blocked.envelope.envelopeId,
|
|
834
|
+
target: blocked.envelope.target,
|
|
835
|
+
blockedAt: blocked.quarantinedAt,
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
|
|
841
|
+
const delivery = requiredTask(store, taskId).terminalDelivery;
|
|
842
|
+
if (delivery.state !== "delivery_blocked") return;
|
|
843
|
+
throw new TaskOutboxDeliveryError(delivery.error.code, "terminal task delivery is blocked", {
|
|
844
|
+
retryable: false,
|
|
845
|
+
details: { ...delivery.error.details, taskId, envelopeId: delivery.envelopeId, origin: delivery.origin, blockedAt: delivery.blockedAt },
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
|
|
850
|
+
const task = store.getTask(taskId);
|
|
851
|
+
if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`, { retryable: false });
|
|
852
|
+
return task;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function statusFor(type: string): TaskRecord["status"] {
|
|
856
|
+
return TASK_EVENT_TYPES[type]?.status ?? "timed_out";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function isInboxPage(value: unknown): value is { readonly deliveries: readonly RelayDelivery[]; readonly nextCursor: string; readonly hasMore: boolean } {
|
|
860
|
+
return isRecord(value) && Array.isArray(value.deliveries) && decimal(value.nextCursor) && typeof value.hasMore === "boolean";
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function isDelivery(value: unknown): value is RelayDelivery {
|
|
864
|
+
return isRecord(value) && decimal(value.cursor) && isEnvelope(value.envelope);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function isEnvelope(value: unknown): value is RelayEnvelope {
|
|
868
|
+
return isRecord(value) && nonEmpty(value.envelopeId) && value.protocolVersion === TASK_PROTOCOL_VERSION && nonEmpty(value.taskId)
|
|
869
|
+
&& isEndpoint(value.source) && isEndpoint(value.target) && isEnvelopeKind(value.kind)
|
|
870
|
+
&& typeof value.payload === "string" && new TextEncoder().encode(value.payload).byteLength <= MAX_RELAY_PAYLOAD_BYTES;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function isTaskRecordInput(value: unknown): value is Omit<TaskRecord, "events"> {
|
|
874
|
+
return isRecord(value) && nonEmpty(value.taskId) && value.protocolVersion === TASK_PROTOCOL_VERSION && isEndpoint(value.origin) && isEndpoint(value.target)
|
|
875
|
+
&& nonEmpty(value.task) && finiteNumber(value.createdAt) && finiteNumber(value.expiresAt) && isTaskStatus(value.status);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isTaskEvent(value: unknown): value is TaskEvent {
|
|
879
|
+
return isRecord(value) && nonEmpty(value.eventId) && nonEmpty(value.taskId) && nonEmpty(value.type) && positiveDecimal(value.sequence)
|
|
880
|
+
&& isEndpoint(value.source) && isEndpoint(value.target) && finiteNumber(value.occurredAt) && isRecord(value.payload);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function isTaskIntent(value: unknown): value is TaskIntent {
|
|
884
|
+
return isRecord(value) && nonEmpty(value.intentId) && nonEmpty(value.taskId) && typeof value.type === "string" && isRecord(value.payload);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function isEndpoint(value: unknown): value is TaskEndpoint {
|
|
888
|
+
return isRecord(value) && nonEmpty(value.relay) && nonEmpty(value.id);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function isEnvelopeKind(value: unknown): value is RelayEnvelope["kind"] {
|
|
892
|
+
return value === TaskEnvelopeKind.assignment || value === TaskEnvelopeKind.intent || value === TaskEnvelopeKind.canonicalEvent;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isTaskStatus(value: unknown): value is TaskRecord["status"] {
|
|
896
|
+
return value === "active" || value === "completed" || value === "failed" || value === "cancelled" || value === "timed_out";
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
|
|
900
|
+
return left.relay === right.relay && left.id === right.id;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
904
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function nonEmpty(value: unknown): value is string {
|
|
908
|
+
return typeof value === "string" && value.length > 0;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function finiteNumber(value: unknown): value is number {
|
|
912
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function decimal(value: unknown): value is string {
|
|
916
|
+
return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function positiveDecimal(value: unknown): value is string {
|
|
920
|
+
return typeof value === "string" && /^[1-9][0-9]*$/.test(value);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
924
|
+
if (signal?.aborted) throw new TaskProtocolError("ABORTED", "task relay connection was cancelled");
|
|
925
|
+
}
|