@sgtbeatdown/pi-tasks 0.1.8 → 0.1.10
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 +49 -20
- package/package.json +2 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +6 -4
- package/src/configured-task-core.ts +63 -0
- package/src/extension.ts +112 -35
- package/src/in-memory-task-relay.ts +6 -5
- package/src/index.ts +6 -7
- package/src/task-core.ts +40 -17
- package/src/task-inbox.ts +18 -4
- package/src/task-protocol.ts +19 -1
- package/src/task-relay-conformance.ts +11 -1
- package/src/task-store.ts +178 -368
- package/src/volatile-task-session.ts +283 -0
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-local-auth.ts +20 -0
- package/src/wolfpack-task-relay.ts +37 -341
- package/src/sqlite-database.ts +0 -68
package/src/task-core.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_RELAY_PAYLOAD_BYTES,
|
|
3
|
+
INVALID_RELAY_METADATA,
|
|
3
4
|
ORIGIN_CANCELLATION_OPERATION,
|
|
4
5
|
PARENT_ACKNOWLEDGMENT_OPERATION,
|
|
5
6
|
TASK_PROTOCOL_VERSION,
|
|
@@ -24,7 +25,7 @@ import type { TaskStore } from "./task-store";
|
|
|
24
25
|
|
|
25
26
|
const RECEIVE_PAGE_SIZE = 100;
|
|
26
27
|
const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
|
|
27
|
-
const
|
|
28
|
+
const TERMINAL_DELIVERY_CODES = new Set(["TARGET_NOT_REGISTERED", INVALID_RELAY_METADATA, "DELIVERY_UNCONFIRMED", "ENVELOPE_EXPIRED", "ENVELOPE_CONFLICT", "CROSS_RELAY_ENDPOINT"]);
|
|
28
29
|
const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
|
|
29
30
|
const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
|
|
30
31
|
const CANONICAL_EVENTS = new Set([
|
|
@@ -84,6 +85,19 @@ type ReceivedEnvelope =
|
|
|
84
85
|
export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
85
86
|
const clock = options.clock ?? { now: (): number => Date.now() };
|
|
86
87
|
const ids = options.ids ?? (() => crypto.randomUUID());
|
|
88
|
+
const acknowledgeRelayDelivery = async (cursor: string, signal?: AbortSignal): Promise<void> => {
|
|
89
|
+
if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
90
|
+
const envelopeId = options.store.pendingRelayEnvelopeId(options.endpoint, cursor);
|
|
91
|
+
if (envelopeId === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
|
|
92
|
+
options.store.transaction(() => options.store.requestRelayAcknowledgement(options.endpoint, cursor));
|
|
93
|
+
await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor, envelopeId }, signal);
|
|
94
|
+
options.store.transaction(() => options.store.acknowledgeRelayCursor(options.endpoint, cursor));
|
|
95
|
+
};
|
|
96
|
+
const retryRequestedAcknowledgements = async (signal?: AbortSignal): Promise<void> => {
|
|
97
|
+
for (const cursor of options.store.requestedRelayAcknowledgements(options.endpoint)) {
|
|
98
|
+
if (options.store.pendingRelayEnvelopeId(options.endpoint, cursor) !== undefined) await acknowledgeRelayDelivery(cursor, signal);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
87
101
|
const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
|
|
88
102
|
if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
|
|
89
103
|
let persistedEnvelopeIds: readonly string[] = [];
|
|
@@ -121,6 +135,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
121
135
|
const connection = await options.relay.connect({ endpoint: options.endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: options.store.getReceiveCursor() }, signal);
|
|
122
136
|
if (!sameEndpoint(connection.endpoint, options.endpoint)) throw new TaskProtocolError("INVALID_CONNECTION", "relay connected a different endpoint");
|
|
123
137
|
throwIfAborted(signal);
|
|
138
|
+
await retryRequestedAcknowledgements(signal);
|
|
124
139
|
},
|
|
125
140
|
async createTask(input, signal) {
|
|
126
141
|
if (input.task.length === 0 || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) throw new TaskProtocolError("INVALID_TASK", "task and a positive timeout are required");
|
|
@@ -133,7 +148,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
133
148
|
createdAt: now, expiresAt: now + input.timeoutMs, status: "active",
|
|
134
149
|
};
|
|
135
150
|
const created = event(task, ids(), "task.created", "1", options.endpoint, target, now, { task: input.task });
|
|
136
|
-
const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created });
|
|
151
|
+
const assignment = envelope(ids(), options.endpoint, target, taskId, TaskEnvelopeKind.assignment, { task, event: created }, now);
|
|
137
152
|
options.store.transaction(() => {
|
|
138
153
|
options.store.putTask(task);
|
|
139
154
|
options.store.appendEvent(created);
|
|
@@ -150,12 +165,23 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
150
165
|
listTasks() { return options.store.listTasks(); },
|
|
151
166
|
async flushOutbox(signal) { await flush(options, signal); },
|
|
152
167
|
async receive(signal) {
|
|
168
|
+
await retryRequestedAcknowledgements(signal);
|
|
153
169
|
await flush(options, signal, IGNORE_OUTBOX_FAILURES);
|
|
154
|
-
const
|
|
170
|
+
const cursor = options.store.getReceiveCursor();
|
|
171
|
+
const page = await options.relay.receive({ endpoint: options.endpoint, cursor, limit: RECEIVE_PAGE_SIZE }, signal);
|
|
155
172
|
if (!isInboxPage(page)) throw new TaskProtocolError("INVALID_INBOX", "relay returned an invalid inbox page");
|
|
173
|
+
let previous = BigInt(cursor);
|
|
174
|
+
for (const delivery of page.deliveries) {
|
|
175
|
+
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");
|
|
176
|
+
previous = BigInt(delivery.cursor);
|
|
177
|
+
}
|
|
178
|
+
if (BigInt(page.nextCursor) < previous) throw new TaskProtocolError("INVALID_INBOX", "relay page cursor precedes its deliveries");
|
|
179
|
+
// Track the entire observed prefix before an intent can ACK a later
|
|
180
|
+
// delivery. Keep only pending IDs plus a high-water mark, durably.
|
|
181
|
+
options.store.transaction(() => options.store.trackRelayDeliveries(options.endpoint, page.deliveries.map(delivery => ({ cursor: delivery.cursor, envelopeId: delivery.envelope.envelopeId }))));
|
|
156
182
|
const visibleDeliveries: RelayDelivery[] = [];
|
|
157
183
|
for (const delivery of page.deliveries) {
|
|
158
|
-
if (
|
|
184
|
+
if (options.store.pendingRelayEnvelopeId(options.endpoint, delivery.cursor) === undefined) continue;
|
|
159
185
|
const received = validateReceivedEnvelope(options, delivery.envelope);
|
|
160
186
|
let persistedEnvelopeIds: readonly string[] = [];
|
|
161
187
|
options.store.transaction(() => {
|
|
@@ -171,11 +197,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
171
197
|
}
|
|
172
198
|
return visibleDeliveries;
|
|
173
199
|
},
|
|
174
|
-
|
|
175
|
-
if (!decimal(cursor)) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor must be a non-negative integer");
|
|
176
|
-
await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
|
|
177
|
-
options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
|
|
178
|
-
},
|
|
200
|
+
acknowledgeRelayDelivery,
|
|
179
201
|
async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
|
|
180
202
|
submitIntentWithOutcome,
|
|
181
203
|
async recordInsertion(input, signal) {
|
|
@@ -279,6 +301,7 @@ interface PersistedIntent {
|
|
|
279
301
|
}
|
|
280
302
|
|
|
281
303
|
function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
|
|
304
|
+
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 });
|
|
282
305
|
if (sameEndpoint(options.endpoint, task.origin)) {
|
|
283
306
|
const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
|
|
284
307
|
const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
|
|
@@ -306,7 +329,7 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
|
|
|
306
329
|
}
|
|
307
330
|
}
|
|
308
331
|
options.store.putIntent(intent.intentId, input.taskId, envelopeId);
|
|
309
|
-
options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
|
|
332
|
+
options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent, now()));
|
|
310
333
|
return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
|
|
311
334
|
}
|
|
312
335
|
|
|
@@ -324,7 +347,7 @@ function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefine
|
|
|
324
347
|
if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
|
|
325
348
|
return { eventId: input.payload.eventId, stage, state, retryable: true };
|
|
326
349
|
}
|
|
327
|
-
if ((stage === TaskDeliveryStage.
|
|
350
|
+
if ((stage === TaskDeliveryStage.receiverRecorded || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
|
|
328
351
|
&& state === TaskDeliveryEvidenceState.confirmed) {
|
|
329
352
|
return { eventId: input.payload.eventId, stage, state };
|
|
330
353
|
}
|
|
@@ -346,8 +369,8 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
|
|
|
346
369
|
const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
|
|
347
370
|
const sequence = String(task.events.length + 1);
|
|
348
371
|
const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
|
|
349
|
-
const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
|
|
350
|
-
const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical);
|
|
372
|
+
const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
|
|
373
|
+
const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
|
|
351
374
|
const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
|
|
352
375
|
if (operation !== undefined) {
|
|
353
376
|
const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
|
|
@@ -378,7 +401,7 @@ async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined,
|
|
|
378
401
|
options.store.transaction(() => { options.store.markOutboxAccepted(record.envelope.envelopeId); });
|
|
379
402
|
} catch (error) {
|
|
380
403
|
if (signal?.aborted || (error instanceof TaskProtocolError && error.code === "ABORTED")) throw error;
|
|
381
|
-
if (error instanceof TaskProtocolError && error.code
|
|
404
|
+
if (error instanceof TaskProtocolError && TERMINAL_DELIVERY_CODES.has(error.code) && !error.retryable) {
|
|
382
405
|
options.store.transaction(() => {
|
|
383
406
|
options.store.quarantineOutbox(record.envelope.envelopeId, {
|
|
384
407
|
errorCode: error.code,
|
|
@@ -413,8 +436,8 @@ function event(task: Omit<TaskRecord, "events">, eventId: string, type: string,
|
|
|
413
436
|
return { eventId, taskId: task.taskId, type, sequence, source, target, occurredAt, payload };
|
|
414
437
|
}
|
|
415
438
|
|
|
416
|
-
function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown): RelayEnvelope {
|
|
417
|
-
return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload) };
|
|
439
|
+
function envelope(envelopeId: string, source: TaskEndpoint, target: TaskEndpoint, taskId: string, kind: RelayEnvelope["kind"], payload: unknown, createdAt: number): RelayEnvelope {
|
|
440
|
+
return { envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId, kind, payload: JSON.stringify(payload), createdAt: new Date(createdAt).toISOString() };
|
|
418
441
|
}
|
|
419
442
|
|
|
420
443
|
function parsePayload(envelope: RelayEnvelope): unknown {
|
|
@@ -460,7 +483,7 @@ function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void
|
|
|
460
483
|
|
|
461
484
|
function requiredTask(store: TaskStore, taskId: string): TaskSnapshot {
|
|
462
485
|
const task = store.getTask(taskId);
|
|
463
|
-
if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}
|
|
486
|
+
if (!task) throw new TaskProtocolError("UNKNOWN_TASK", `unknown task: ${taskId}`, { retryable: false });
|
|
464
487
|
return task;
|
|
465
488
|
}
|
|
466
489
|
|
package/src/task-inbox.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { RelayDelivery, TaskEvent } from "./task-protocol";
|
|
|
5
5
|
const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
|
|
6
6
|
const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
|
|
7
7
|
const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
|
|
8
|
+
const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
|
|
8
9
|
|
|
9
10
|
interface InboxContext {
|
|
10
11
|
readonly isIdle: () => boolean;
|
|
@@ -17,16 +18,18 @@ interface InboxPi {
|
|
|
17
18
|
message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
|
|
18
19
|
options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
|
|
19
20
|
): void;
|
|
20
|
-
appendEntry(customType: string, data: { readonly cursor: string }): void;
|
|
21
|
+
appendEntry(customType: string, data: { readonly cursor: string } | TaskEventDetails): void;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface TaskEventDetails {
|
|
24
25
|
readonly taskId: string;
|
|
25
26
|
readonly eventId: string;
|
|
27
|
+
/** Historical evidence only; never used to rebuild active task state. */
|
|
28
|
+
readonly event?: TaskEvent;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
type DeliveryEvidencePayload =
|
|
29
|
-
| { readonly stage: typeof TaskDeliveryStage.
|
|
32
|
+
| { readonly stage: typeof TaskDeliveryStage.receiverRecorded | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
|
|
30
33
|
| { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
|
|
31
34
|
|
|
32
35
|
/** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
|
|
@@ -44,8 +47,13 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
44
47
|
if (context.hasPendingMessages()) return;
|
|
45
48
|
const eventDetails = { taskId: event.taskId, eventId: event.eventId };
|
|
46
49
|
const eventKey = key(event.taskId, event.eventId);
|
|
50
|
+
if (!isModelVisible(event.type) && !recordedEventKeys(context.sessionManager.getEntries()).has(eventKey)) {
|
|
51
|
+
// Archive receipts/parent ACK/late-terminal facts without waking the model.
|
|
52
|
+
// This is evidence only: no task state or delivery queue is reconstructed.
|
|
53
|
+
pi.appendEntry(TASK_RECORD_CUSTOM_TYPE, { ...eventDetails, event });
|
|
54
|
+
}
|
|
47
55
|
if (event.type === "task.created") {
|
|
48
|
-
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.
|
|
56
|
+
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverRecorded, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
49
57
|
}
|
|
50
58
|
let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
51
59
|
if (!incorporated && isModelVisible(event.type)) {
|
|
@@ -54,7 +62,7 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
54
62
|
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
55
63
|
content: renderTaskEvent(event),
|
|
56
64
|
display: true,
|
|
57
|
-
details: eventDetails,
|
|
65
|
+
details: { ...eventDetails, event },
|
|
58
66
|
}, { triggerTurn: false });
|
|
59
67
|
incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
60
68
|
if (!incorporated) {
|
|
@@ -126,6 +134,12 @@ function taskMessageDetails(entries: readonly unknown[], customType: string): re
|
|
|
126
134
|
return events;
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
function recordedEventKeys(entries: readonly unknown[]): Set<string> {
|
|
138
|
+
return new Set(entries.flatMap(entry => isRecord(entry) && entry.type === "custom" && entry.customType === TASK_RECORD_CUSTOM_TYPE
|
|
139
|
+
&& isRecord(entry.data) && typeof entry.data.taskId === "string" && typeof entry.data.eventId === "string"
|
|
140
|
+
? [key(entry.data.taskId, entry.data.eventId)] : []));
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
|
|
130
144
|
return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
|
|
131
145
|
}
|
package/src/task-protocol.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
|
|
2
2
|
export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
|
|
3
|
+
export const INVALID_RELAY_METADATA = "INVALID_RELAY_METADATA";
|
|
3
4
|
export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
|
|
4
5
|
export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
|
|
5
6
|
export const TERMINAL_INTENT_OPERATION = "terminal_intent";
|
|
6
7
|
|
|
7
8
|
export const TaskDeliveryStage = {
|
|
8
|
-
|
|
9
|
+
receiverRecorded: "receiver_recorded",
|
|
9
10
|
piInsertion: "pi_insertion",
|
|
10
11
|
piInserted: "pi_inserted",
|
|
11
12
|
wakeRequested: "wake_requested",
|
|
@@ -22,6 +23,17 @@ export interface TaskEndpoint {
|
|
|
22
23
|
readonly id: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Endpoint-owned transport binding; not task lifecycle or relay replay state. */
|
|
27
|
+
export interface RelayTransportBinding {
|
|
28
|
+
readonly profile: "volatile-v1";
|
|
29
|
+
readonly epoch: string;
|
|
30
|
+
readonly endpoint: TaskEndpoint;
|
|
31
|
+
readonly generation: string;
|
|
32
|
+
readonly callerSession: string;
|
|
33
|
+
readonly url: string;
|
|
34
|
+
readonly reset?: true;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/** A provider-defined reference which core resolves without interpreting. */
|
|
26
38
|
export interface RelayTargetReference {
|
|
27
39
|
readonly relay: string;
|
|
@@ -54,6 +66,9 @@ export interface RelayEnvelope {
|
|
|
54
66
|
readonly target: TaskEndpoint;
|
|
55
67
|
readonly taskId: string;
|
|
56
68
|
readonly kind: TaskEnvelopeKind;
|
|
69
|
+
/** Immutable transport creation time, persisted with new outbox envelopes.
|
|
70
|
+
* Received/legacy records may omit it; Wolfpack sends require it. */
|
|
71
|
+
readonly createdAt?: string;
|
|
57
72
|
/** Opaque to relays. The endpoint protocol defines its JSON representation. */
|
|
58
73
|
readonly payload: string;
|
|
59
74
|
}
|
|
@@ -82,6 +97,8 @@ export interface RelayInboxPage {
|
|
|
82
97
|
export interface RelayDeliveryAck {
|
|
83
98
|
readonly endpoint: TaskEndpoint;
|
|
84
99
|
readonly cursor: string;
|
|
100
|
+
/** Durable cursor-to-envelope binding, for identical ACK retry after reopen. */
|
|
101
|
+
readonly envelopeId?: string;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
export interface TaskRelay {
|
|
@@ -90,6 +107,7 @@ export interface TaskRelay {
|
|
|
90
107
|
resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
|
|
91
108
|
send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
|
|
92
109
|
receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
|
|
110
|
+
/** Acknowledge this delivery only, never an implicit cursor prefix. */
|
|
93
111
|
acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
|
|
94
112
|
}
|
|
95
113
|
|
|
@@ -5,6 +5,16 @@ import type { TaskRelay } from "./task-protocol";
|
|
|
5
5
|
|
|
6
6
|
/** Shared assertions external relay adapters can run against their implementation. */
|
|
7
7
|
export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
|
|
8
|
+
test("acknowledging a later delivery retains earlier unacknowledged mail", async () => {
|
|
9
|
+
const relay = createRelay();
|
|
10
|
+
const source = { relay: relay.id, id: "source" }, target = { relay: relay.id, id: "target" };
|
|
11
|
+
for (const endpoint of [source, target]) await relay.connect({ endpoint, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
12
|
+
for (const envelopeId of ["first", "second"]) await relay.send({ envelopeId, protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task", kind: "assignment", payload: "{}", createdAt: new Date().toISOString() });
|
|
13
|
+
const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
|
|
14
|
+
expect(page.deliveries).toHaveLength(2);
|
|
15
|
+
await relay.acknowledgeDelivery({ endpoint: target, cursor: page.deliveries[1]!.cursor });
|
|
16
|
+
expect((await relay.receive({ endpoint: target, cursor: "0", limit: 10 })).deliveries.map(d => d.envelope.envelopeId)).toContain("first");
|
|
17
|
+
});
|
|
8
18
|
test("enforces registration, accepts duplicate envelopes once, and retains delivery until acknowledgement", async () => {
|
|
9
19
|
const relay = createRelay();
|
|
10
20
|
const source = { relay: relay.id, id: "source" };
|
|
@@ -12,7 +22,7 @@ export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
|
|
|
12
22
|
await relay.connect({ endpoint: source, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
13
23
|
await relay.connect({ endpoint: target, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
14
24
|
await expect(relay.resolve({ relay: relay.id, reference: "missing" })).rejects.toThrow("not registered");
|
|
15
|
-
const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}" };
|
|
25
|
+
const envelope = { envelopeId: "envelope-1", protocolVersion: TASK_PROTOCOL_VERSION, source, target, taskId: "task-1", kind: "assignment" as const, payload: "{}", createdAt: new Date(0).toISOString() };
|
|
16
26
|
await relay.send(envelope);
|
|
17
27
|
await relay.send(envelope);
|
|
18
28
|
const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
|