@sgtbeatdown/pi-tasks 0.1.5 → 0.1.7
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 +23 -118
- package/package.json +3 -5
- package/skills/wolfpack-pi-task-delegation/SKILL.md +33 -21
- package/src/extension.ts +112 -28
- package/src/index.ts +1 -1
- package/src/task-core.ts +128 -38
- package/src/task-inbox.ts +58 -17
- package/src/task-protocol.ts +16 -0
- package/src/task-store.ts +28 -141
- package/src/gateway-client.ts +0 -409
- package/src/legacy-extension.ts +0 -241
- package/src/legacy-task-inbox.ts +0 -148
- package/src/metrics-cli.ts +0 -20
- package/src/metrics.ts +0 -288
- package/src/task-artifacts.ts +0 -153
- package/src/task-board.ts +0 -365
- package/src/v1-compat-extension.ts +0 -1
package/src/task-core.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
PARENT_ACKNOWLEDGMENT_OPERATION,
|
|
5
5
|
TASK_PROTOCOL_VERSION,
|
|
6
6
|
TERMINAL_INTENT_OPERATION,
|
|
7
|
+
TaskDeliveryEvidenceState,
|
|
8
|
+
TaskDeliveryStage,
|
|
7
9
|
TaskEnvelopeKind,
|
|
8
10
|
TaskOutboxDeliveryError,
|
|
9
11
|
TaskProtocolError,
|
|
@@ -21,6 +23,7 @@ import type {
|
|
|
21
23
|
import type { TaskStore } from "./task-store";
|
|
22
24
|
|
|
23
25
|
const RECEIVE_PAGE_SIZE = 100;
|
|
26
|
+
const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
|
|
24
27
|
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
25
28
|
const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
|
|
26
29
|
const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
|
|
@@ -53,6 +56,10 @@ export interface SubmitIntentInput {
|
|
|
53
56
|
readonly payload: Record<string, unknown>;
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
export type SubmitIntentOutcome =
|
|
60
|
+
| { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
|
|
61
|
+
| { readonly authority: "receiver" };
|
|
62
|
+
|
|
56
63
|
export interface TaskCore {
|
|
57
64
|
readonly endpoint: TaskEndpoint;
|
|
58
65
|
connect(signal?: AbortSignal): Promise<void>;
|
|
@@ -63,6 +70,7 @@ export interface TaskCore {
|
|
|
63
70
|
receive(signal?: AbortSignal): Promise<readonly RelayDelivery[]>;
|
|
64
71
|
acknowledgeRelayDelivery(cursor: string, signal?: AbortSignal): Promise<void>;
|
|
65
72
|
submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
|
|
73
|
+
submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
|
|
66
74
|
recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
|
|
67
75
|
evaluateTimeouts(signal?: AbortSignal): Promise<void>;
|
|
68
76
|
acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
|
|
@@ -76,6 +84,36 @@ type ReceivedEnvelope =
|
|
|
76
84
|
export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
77
85
|
const clock = options.clock ?? { now: (): number => Date.now() };
|
|
78
86
|
const ids = options.ids ?? (() => crypto.randomUUID());
|
|
87
|
+
const submitIntentWithOutcome = async (input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome> => {
|
|
88
|
+
if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
|
|
89
|
+
let persistedEnvelopeIds: readonly string[] = [];
|
|
90
|
+
let outcome: SubmitIntentOutcome | undefined;
|
|
91
|
+
let receiverTerminal = false;
|
|
92
|
+
let originCancellation = false;
|
|
93
|
+
const evidence = deliveryEvidence(input);
|
|
94
|
+
options.store.transaction(() => {
|
|
95
|
+
const task = requiredTask(options.store, input.taskId);
|
|
96
|
+
receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
|
|
97
|
+
originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
|
|
98
|
+
const persisted = persistIntent(options, clock.now, ids, task, input, evidence === undefined ? undefined : deliveryEvidenceOperation(evidence));
|
|
99
|
+
persistedEnvelopeIds = persisted.envelopeIds;
|
|
100
|
+
outcome = persisted.outcome;
|
|
101
|
+
});
|
|
102
|
+
if (evidence !== undefined) {
|
|
103
|
+
await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
|
|
104
|
+
} else {
|
|
105
|
+
try {
|
|
106
|
+
await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
112
|
+
}
|
|
113
|
+
if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
|
|
114
|
+
if (outcome === undefined) throw new TaskProtocolError("INVALID_INTENT", "task intent outcome was not persisted");
|
|
115
|
+
return outcome;
|
|
116
|
+
};
|
|
79
117
|
|
|
80
118
|
return {
|
|
81
119
|
endpoint: options.endpoint,
|
|
@@ -138,35 +176,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
138
176
|
await options.relay.acknowledgeDelivery({ endpoint: options.endpoint, cursor }, signal);
|
|
139
177
|
options.store.transaction(() => { options.store.setReceiveCursor(cursor); });
|
|
140
178
|
},
|
|
141
|
-
async submitIntent(input, signal) {
|
|
142
|
-
|
|
143
|
-
let persistedEnvelopeIds: readonly string[] = [];
|
|
144
|
-
let receiverTerminal = false;
|
|
145
|
-
let originCancellation = false;
|
|
146
|
-
options.store.transaction(() => {
|
|
147
|
-
const task = requiredTask(options.store, input.taskId);
|
|
148
|
-
receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
|
|
149
|
-
originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
|
|
150
|
-
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
|
|
151
|
-
});
|
|
152
|
-
try {
|
|
153
|
-
await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
156
|
-
throw error;
|
|
157
|
-
}
|
|
158
|
-
if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
|
|
159
|
-
if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
|
|
160
|
-
},
|
|
179
|
+
async submitIntent(input, signal) { await submitIntentWithOutcome(input, signal); },
|
|
180
|
+
submitIntentWithOutcome,
|
|
161
181
|
async recordInsertion(input, signal) {
|
|
162
182
|
const task = requiredTask(options.store, input.taskId);
|
|
183
|
+
const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.piInserted, state: TaskDeliveryEvidenceState.confirmed };
|
|
163
184
|
let persistedEnvelopeIds: readonly string[] = [];
|
|
164
185
|
options.store.transaction(() => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
options.store.putInsertionReceipt(input.taskId, input.eventId);
|
|
187
|
+
persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
|
|
188
|
+
taskId: input.taskId,
|
|
189
|
+
type: "task.delivery_receipt",
|
|
190
|
+
payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
|
|
191
|
+
}, deliveryEvidenceOperation(evidence)).envelopeIds;
|
|
168
192
|
});
|
|
169
|
-
|
|
193
|
+
await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
|
|
170
194
|
},
|
|
171
195
|
async evaluateTimeouts(signal) {
|
|
172
196
|
const persistedEnvelopeIds: string[] = [];
|
|
@@ -175,7 +199,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
175
199
|
options.store.transaction(() => {
|
|
176
200
|
const task = options.store.getTask(candidate.taskId);
|
|
177
201
|
if (!task || !sameEndpoint(task.origin, options.endpoint) || TERMINAL_STATUSES.has(task.status) || task.expiresAt > clock.now()) return;
|
|
178
|
-
persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out"));
|
|
202
|
+
persistedEnvelopeIds.push(...canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId: task.taskId, type: "task.cancelled", payload: {} }, "task.timed_out").envelopeIds);
|
|
179
203
|
});
|
|
180
204
|
}
|
|
181
205
|
if (persistedEnvelopeIds.length > 0) await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
@@ -186,7 +210,7 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
|
|
|
186
210
|
const task = requiredTask(options.store, taskId);
|
|
187
211
|
if (!sameEndpoint(task.origin, options.endpoint)) throw new TaskProtocolError("NOT_ORIGIN", "only origin may acknowledge a task");
|
|
188
212
|
if (!TERMINAL_STATUSES.has(task.status)) throw new TaskProtocolError("TASK_NOT_TERMINAL", "only a terminal task may be acknowledged", { retryable: false });
|
|
189
|
-
persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION);
|
|
213
|
+
persistedEnvelopeIds = canonicalize(options, clock.now, ids, task, { intentId: ids(), taskId, type: "task.information", payload: {} }, "task.parent_acknowledged", PARENT_ACKNOWLEDGMENT_OPERATION).envelopeIds;
|
|
190
214
|
});
|
|
191
215
|
await flush(options, signal, new Set(persistedEnvelopeIds));
|
|
192
216
|
},
|
|
@@ -244,42 +268,80 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
|
|
|
244
268
|
options.store.appendEvent(received.event);
|
|
245
269
|
return [];
|
|
246
270
|
}
|
|
247
|
-
if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type);
|
|
271
|
+
if (received.kind === "intent") return canonicalize(options, now, ids, received.task, received.intent, received.intent.type).envelopeIds;
|
|
248
272
|
if (options.store.appendEvent(received.event) && TERMINAL_EVENTS.has(received.event.type)) options.store.setStatus(received.task.taskId, statusFor(received.event.type));
|
|
249
273
|
return [];
|
|
250
274
|
}
|
|
251
275
|
|
|
252
|
-
|
|
276
|
+
interface PersistedIntent {
|
|
277
|
+
readonly envelopeIds: readonly string[];
|
|
278
|
+
readonly outcome: SubmitIntentOutcome;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput, reservedOperation?: string): PersistedIntent {
|
|
253
282
|
if (sameEndpoint(options.endpoint, task.origin)) {
|
|
254
|
-
const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION :
|
|
255
|
-
|
|
283
|
+
const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
|
|
284
|
+
const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
|
|
285
|
+
return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
|
|
256
286
|
}
|
|
257
287
|
const envelopeId = ids();
|
|
258
288
|
const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
|
|
259
|
-
|
|
289
|
+
const operation = TERMINAL_EVENTS.has(input.type) ? TERMINAL_INTENT_OPERATION : reservedOperation;
|
|
290
|
+
if (operation !== undefined) {
|
|
260
291
|
const reservation = options.store.reserveTaskOperation({
|
|
261
292
|
taskId: input.taskId,
|
|
262
|
-
operation
|
|
293
|
+
operation,
|
|
263
294
|
logicalId: intent.intentId,
|
|
264
295
|
logicalType: intent.type,
|
|
265
296
|
envelopeIds: [envelopeId],
|
|
266
297
|
});
|
|
267
298
|
if (!reservation.created) {
|
|
268
|
-
if (reservation.record.logicalType !== intent.type) {
|
|
299
|
+
if (operation === TERMINAL_INTENT_OPERATION && reservation.record.logicalType !== intent.type) {
|
|
269
300
|
throw new TaskProtocolError("TERMINAL_INTENT_CONFLICT", "terminal task intent conflicts with the existing terminal action", {
|
|
270
301
|
retryable: false,
|
|
271
302
|
details: { taskId: input.taskId, existingType: reservation.record.logicalType, requestedType: intent.type },
|
|
272
303
|
});
|
|
273
304
|
}
|
|
274
|
-
return reservation.record.envelopeIds;
|
|
305
|
+
return { envelopeIds: reservation.record.envelopeIds, outcome: { authority: "receiver" } };
|
|
275
306
|
}
|
|
276
307
|
}
|
|
277
308
|
options.store.putIntent(intent.intentId, input.taskId, envelopeId);
|
|
278
309
|
options.store.putOutbox(envelope(envelopeId, options.endpoint, task.origin, input.taskId, TaskEnvelopeKind.intent, intent));
|
|
279
|
-
return [envelopeId];
|
|
310
|
+
return { envelopeIds: [envelopeId], outcome: { authority: "receiver" } };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface DeliveryEvidence {
|
|
314
|
+
readonly eventId: string;
|
|
315
|
+
readonly stage: TaskDeliveryStage;
|
|
316
|
+
readonly state: TaskDeliveryEvidenceState;
|
|
317
|
+
readonly retryable?: true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function deliveryEvidence(input: SubmitIntentInput): DeliveryEvidence | undefined {
|
|
321
|
+
if (input.type !== "task.delivery_receipt" || typeof input.payload.eventId !== "string") return undefined;
|
|
322
|
+
const stage = input.payload.stage;
|
|
323
|
+
const state = input.payload.state;
|
|
324
|
+
if (stage === TaskDeliveryStage.piInsertion && state === TaskDeliveryEvidenceState.blocked && input.payload.retryable === true) {
|
|
325
|
+
return { eventId: input.payload.eventId, stage, state, retryable: true };
|
|
326
|
+
}
|
|
327
|
+
if ((stage === TaskDeliveryStage.receiverPersisted || stage === TaskDeliveryStage.piInserted || stage === TaskDeliveryStage.wakeRequested || stage === TaskDeliveryStage.wakeAccepted)
|
|
328
|
+
&& state === TaskDeliveryEvidenceState.confirmed) {
|
|
329
|
+
return { eventId: input.payload.eventId, stage, state };
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function deliveryEvidenceOperation(input: DeliveryEvidence): string {
|
|
335
|
+
return `${DELIVERY_EVIDENCE_OPERATION}:${input.eventId}:${input.stage}:${input.state}`;
|
|
280
336
|
}
|
|
281
337
|
|
|
282
|
-
|
|
338
|
+
interface Canonicalization {
|
|
339
|
+
readonly envelopeIds: readonly string[];
|
|
340
|
+
readonly eventType: string;
|
|
341
|
+
readonly reused: boolean;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): Canonicalization {
|
|
283
345
|
const terminal = TERMINAL_EVENTS.has(requestedType);
|
|
284
346
|
const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
|
|
285
347
|
const sequence = String(task.events.length + 1);
|
|
@@ -289,13 +351,23 @@ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => st
|
|
|
289
351
|
const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
|
|
290
352
|
if (operation !== undefined) {
|
|
291
353
|
const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
|
|
292
|
-
if (!reservation.created) return reservation.record.envelopeIds;
|
|
354
|
+
if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
|
|
293
355
|
}
|
|
294
356
|
options.store.appendEvent(canonical);
|
|
295
357
|
if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
|
|
296
358
|
options.store.putOutbox(targetEnvelope);
|
|
297
359
|
if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
|
|
298
|
-
return persistedEnvelopeIds;
|
|
360
|
+
return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function flushDeliveryEvidence(options: TaskCoreOptions, signal: AbortSignal | undefined, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): Promise<void> {
|
|
364
|
+
try {
|
|
365
|
+
if (envelopeIds.length > 0) await flush(options, signal, new Set(envelopeIds));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
throwIfDeliveryEvidenceBlocked(options.store, taskId, envelopeIds, evidence);
|
|
299
371
|
}
|
|
300
372
|
|
|
301
373
|
async function flush(options: TaskCoreOptions, signal: AbortSignal | undefined, reportedEnvelopeIds?: ReadonlySet<string>): Promise<void> {
|
|
@@ -359,6 +431,24 @@ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: st
|
|
|
359
431
|
});
|
|
360
432
|
}
|
|
361
433
|
|
|
434
|
+
function throwIfDeliveryEvidenceBlocked(store: TaskStore, taskId: string, envelopeIds: readonly string[], evidence: DeliveryEvidence): void {
|
|
435
|
+
const blocked = store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId));
|
|
436
|
+
if (blocked === undefined) return;
|
|
437
|
+
throw new TaskOutboxDeliveryError(blocked.errorCode, "task delivery evidence is blocked", {
|
|
438
|
+
retryable: false,
|
|
439
|
+
details: {
|
|
440
|
+
...blocked.details,
|
|
441
|
+
taskId,
|
|
442
|
+
eventId: evidence.eventId,
|
|
443
|
+
stage: evidence.stage,
|
|
444
|
+
state: evidence.state,
|
|
445
|
+
envelopeId: blocked.envelope.envelopeId,
|
|
446
|
+
target: blocked.envelope.target,
|
|
447
|
+
blockedAt: blocked.quarantinedAt,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
362
452
|
function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
|
|
363
453
|
const delivery = requiredTask(store, taskId).terminalDelivery;
|
|
364
454
|
if (delivery.state !== "delivery_blocked") return;
|
package/src/task-inbox.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { TaskCore } from "./task-core";
|
|
2
|
-
import { TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
|
|
2
|
+
import { TaskDeliveryEvidenceState, TaskDeliveryStage, TaskEnvelopeKind, TaskProtocolError } from "./task-protocol";
|
|
3
3
|
import type { RelayDelivery, TaskEvent } from "./task-protocol";
|
|
4
4
|
|
|
5
5
|
const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
|
|
6
|
+
const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
|
|
6
7
|
const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
|
|
7
8
|
|
|
8
9
|
interface InboxContext {
|
|
10
|
+
readonly isIdle: () => boolean;
|
|
9
11
|
readonly hasPendingMessages: () => boolean;
|
|
10
12
|
readonly sessionManager: { readonly getEntries: () => readonly unknown[] };
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
interface InboxPi {
|
|
14
|
-
sendMessage(
|
|
16
|
+
sendMessage(
|
|
17
|
+
message: { readonly customType: string; readonly content: string; readonly display: boolean; readonly details: TaskEventDetails },
|
|
18
|
+
options: { readonly triggerTurn: false } | { readonly triggerTurn: true; readonly deliverAs: "followUp" },
|
|
19
|
+
): void;
|
|
15
20
|
appendEntry(customType: string, data: { readonly cursor: string }): void;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -20,8 +25,12 @@ export interface TaskEventDetails {
|
|
|
20
25
|
readonly eventId: string;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
type DeliveryEvidencePayload =
|
|
29
|
+
| { readonly stage: typeof TaskDeliveryStage.receiverPersisted | typeof TaskDeliveryStage.wakeRequested | typeof TaskDeliveryStage.wakeAccepted; readonly state: typeof TaskDeliveryEvidenceState.confirmed }
|
|
30
|
+
| { readonly stage: typeof TaskDeliveryStage.piInsertion; readonly state: typeof TaskDeliveryEvidenceState.blocked; readonly retryable: true };
|
|
31
|
+
|
|
32
|
+
/** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
|
|
33
|
+
export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<void> {
|
|
25
34
|
if (context.hasPendingMessages()) return;
|
|
26
35
|
const deliveries = await core.receive(signal);
|
|
27
36
|
for (const delivery of deliveries) {
|
|
@@ -33,28 +42,52 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
|
|
|
33
42
|
const event = inboxEvent(delivery);
|
|
34
43
|
if (!isKnownEvent(event.type)) throw new TaskProtocolError("UNKNOWN_EVENT", `unknown task inbox event type: ${event.type}`);
|
|
35
44
|
if (context.hasPendingMessages()) return;
|
|
45
|
+
const eventDetails = { taskId: event.taskId, eventId: event.eventId };
|
|
36
46
|
const eventKey = key(event.taskId, event.eventId);
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
if (event.type === "task.created") {
|
|
48
|
+
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.receiverPersisted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
49
|
+
}
|
|
50
|
+
let incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
39
51
|
if (!incorporated && isModelVisible(event.type)) {
|
|
40
|
-
if (!
|
|
52
|
+
if (!context.isIdle()) return;
|
|
53
|
+
pi.sendMessage({
|
|
54
|
+
customType: TASK_EVENT_CUSTOM_TYPE,
|
|
55
|
+
content: renderTaskEvent(event),
|
|
56
|
+
display: true,
|
|
57
|
+
details: eventDetails,
|
|
58
|
+
}, { triggerTurn: false });
|
|
59
|
+
incorporated = incorporatedEvents(context.sessionManager.getEntries()).has(eventKey);
|
|
60
|
+
if (!incorporated) {
|
|
61
|
+
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.piInsertion, state: TaskDeliveryEvidenceState.blocked, retryable: true }, signal);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (isModelVisible(event.type)) {
|
|
66
|
+
await core.recordInsertion(eventDetails, signal);
|
|
67
|
+
let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
68
|
+
if (!wakeAccepted) {
|
|
69
|
+
if (!context.isIdle()) return;
|
|
70
|
+
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
41
71
|
pi.sendMessage({
|
|
42
|
-
customType:
|
|
43
|
-
content:
|
|
44
|
-
display:
|
|
45
|
-
details:
|
|
72
|
+
customType: TASK_WAKE_CUSTOM_TYPE,
|
|
73
|
+
content: "Process the pending Pi task event.",
|
|
74
|
+
display: false,
|
|
75
|
+
details: eventDetails,
|
|
46
76
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
47
|
-
|
|
77
|
+
wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
|
|
78
|
+
if (!wakeAccepted) return;
|
|
48
79
|
}
|
|
49
|
-
|
|
50
|
-
pendingInsertions.delete(eventKey);
|
|
80
|
+
await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
|
|
51
81
|
}
|
|
52
|
-
if (isModelVisible(event.type)) await core.recordInsertion({ taskId: event.taskId, eventId: event.eventId }, signal);
|
|
53
82
|
await core.acknowledgeRelayDelivery(delivery.cursor, signal);
|
|
54
83
|
pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
|
|
55
84
|
}
|
|
56
85
|
}
|
|
57
86
|
|
|
87
|
+
async function recordDeliveryEvidence(core: TaskCore, event: TaskEventDetails, evidence: DeliveryEvidencePayload, signal?: AbortSignal): Promise<void> {
|
|
88
|
+
await core.submitIntent({ taskId: event.taskId, type: "task.delivery_receipt", payload: { eventId: event.eventId, ...evidence } }, signal);
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
function inboxEvent(delivery: RelayDelivery): TaskEvent {
|
|
59
92
|
try {
|
|
60
93
|
const payload = JSON.parse(delivery.envelope.payload) as unknown;
|
|
@@ -81,16 +114,24 @@ function renderTaskEvent(event: TaskEvent): string {
|
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
export function incorporatedTaskEvents(entries: readonly unknown[]): readonly TaskEventDetails[] {
|
|
117
|
+
return taskMessageDetails(entries, TASK_EVENT_CUSTOM_TYPE);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function taskMessageDetails(entries: readonly unknown[], customType: string): readonly TaskEventDetails[] {
|
|
84
121
|
const events: TaskEventDetails[] = [];
|
|
85
122
|
for (const entry of entries) {
|
|
86
|
-
if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !==
|
|
123
|
+
if (!isRecord(entry) || entry.type !== "custom_message" || entry.customType !== customType || !isRecord(entry.details) || typeof entry.details.taskId !== "string" || typeof entry.details.eventId !== "string") continue;
|
|
87
124
|
events.push({ taskId: entry.details.taskId, eventId: entry.details.eventId });
|
|
88
125
|
}
|
|
89
126
|
return events;
|
|
90
127
|
}
|
|
91
128
|
|
|
129
|
+
function taskMessageKeys(entries: readonly unknown[], customType: string): Set<string> {
|
|
130
|
+
return new Set(taskMessageDetails(entries, customType).map((event) => key(event.taskId, event.eventId)));
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
function incorporatedEvents(entries: readonly unknown[]): Set<string> {
|
|
93
|
-
return
|
|
134
|
+
return taskMessageKeys(entries, TASK_EVENT_CUSTOM_TYPE);
|
|
94
135
|
}
|
|
95
136
|
|
|
96
137
|
function isTaskEvent(value: unknown): value is TaskEvent {
|
package/src/task-protocol.ts
CHANGED
|
@@ -4,6 +4,19 @@ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
|
|
|
4
4
|
export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
|
|
5
5
|
export const TERMINAL_INTENT_OPERATION = "terminal_intent";
|
|
6
6
|
|
|
7
|
+
export const TaskDeliveryStage = {
|
|
8
|
+
receiverPersisted: "receiver_persisted",
|
|
9
|
+
piInsertion: "pi_insertion",
|
|
10
|
+
piInserted: "pi_inserted",
|
|
11
|
+
wakeRequested: "wake_requested",
|
|
12
|
+
wakeAccepted: "wake_accepted",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const TaskDeliveryEvidenceState = {
|
|
16
|
+
confirmed: "confirmed",
|
|
17
|
+
blocked: "blocked",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
7
20
|
export interface TaskEndpoint {
|
|
8
21
|
readonly relay: string;
|
|
9
22
|
readonly id: string;
|
|
@@ -105,6 +118,9 @@ export interface TaskRecord {
|
|
|
105
118
|
|
|
106
119
|
export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
|
|
107
120
|
|
|
121
|
+
export type TaskDeliveryStage = (typeof TaskDeliveryStage)[keyof typeof TaskDeliveryStage];
|
|
122
|
+
export type TaskDeliveryEvidenceState = (typeof TaskDeliveryEvidenceState)[keyof typeof TaskDeliveryEvidenceState];
|
|
123
|
+
|
|
108
124
|
export interface TaskIntent {
|
|
109
125
|
readonly intentId: string;
|
|
110
126
|
readonly taskId: string;
|
package/src/task-store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { openSqliteDatabase } from "./sqlite-database";
|
|
6
6
|
import type { SqliteDatabase } from "./sqlite-database";
|
|
7
|
-
import {
|
|
7
|
+
import { TERMINAL_INTENT_OPERATION } from "./task-protocol";
|
|
8
8
|
import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
|
|
9
9
|
|
|
10
10
|
const SCHEMA_VERSION = 5;
|
|
@@ -71,8 +71,13 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
|
|
|
71
71
|
const path = options.path ?? join(homedir(), ".pi", "tasks", "v2", "tasks.sqlite");
|
|
72
72
|
preparePath(path);
|
|
73
73
|
const database = openSqliteDatabase(path);
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
try {
|
|
75
|
+
initializeSchema(database);
|
|
76
|
+
database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
database.close();
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
76
81
|
|
|
77
82
|
const store: TaskStore = {
|
|
78
83
|
transaction(operation) {
|
|
@@ -213,17 +218,6 @@ interface TaskOperationRow {
|
|
|
213
218
|
readonly envelope_ids: string;
|
|
214
219
|
}
|
|
215
220
|
|
|
216
|
-
interface LegacyIntentRow {
|
|
217
|
-
readonly intent_id: string;
|
|
218
|
-
readonly task_id: string;
|
|
219
|
-
readonly envelope_id: string;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
interface LegacyEnvelopeRow {
|
|
223
|
-
readonly envelope_id: string;
|
|
224
|
-
readonly envelope: string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
221
|
interface EventRow {
|
|
228
222
|
readonly event_id: string;
|
|
229
223
|
readonly task_id: string;
|
|
@@ -237,10 +231,11 @@ interface EventRow {
|
|
|
237
231
|
readonly payload: string;
|
|
238
232
|
}
|
|
239
233
|
|
|
240
|
-
function
|
|
234
|
+
function initializeSchema(database: SqliteDatabase): void {
|
|
241
235
|
const version = database.query("PRAGMA user_version").get() as { readonly user_version: number };
|
|
242
|
-
if (version.user_version
|
|
243
|
-
if (version.user_version
|
|
236
|
+
if (version.user_version === SCHEMA_VERSION) return;
|
|
237
|
+
if (version.user_version !== 0) throw new Error("task store schema version is unsupported");
|
|
238
|
+
database.transaction(() => {
|
|
244
239
|
database.exec(`
|
|
245
240
|
CREATE TABLE tasks (task_id TEXT PRIMARY KEY, protocol_version TEXT NOT NULL, origin_relay TEXT NOT NULL, origin_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, task TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL);
|
|
246
241
|
CREATE TABLE events (event_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id), type TEXT NOT NULL, sequence TEXT NOT NULL, source_relay TEXT NOT NULL, source_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, occurred_at INTEGER NOT NULL, payload TEXT NOT NULL);
|
|
@@ -248,137 +243,29 @@ function migrate(database: SqliteDatabase): void {
|
|
|
248
243
|
CREATE TABLE inbox (envelope_id TEXT PRIMARY KEY, cursor TEXT NOT NULL, envelope TEXT NOT NULL);
|
|
249
244
|
CREATE TABLE intents (intent_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, envelope_id TEXT NOT NULL);
|
|
250
245
|
CREATE TABLE relay_state (name TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
251
|
-
PRAGMA user_version = 1;
|
|
252
|
-
`);
|
|
253
|
-
}
|
|
254
|
-
if (version.user_version <= 1) {
|
|
255
|
-
database.exec(`
|
|
256
|
-
CREATE INDEX IF NOT EXISTS tasks_origin_expiry ON tasks (origin_relay, origin_id, status, expires_at);
|
|
257
|
-
CREATE INDEX IF NOT EXISTS inbox_cursor ON inbox (cursor);
|
|
258
|
-
PRAGMA user_version = 2;
|
|
259
|
-
`);
|
|
260
|
-
}
|
|
261
|
-
if (version.user_version <= 2) {
|
|
262
|
-
database.exec(`
|
|
263
246
|
CREATE TABLE insertion_receipts (task_id TEXT NOT NULL REFERENCES tasks(task_id), event_id TEXT NOT NULL, PRIMARY KEY (task_id, event_id));
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
details TEXT NOT NULL DEFAULT '{}',
|
|
275
|
-
prior_state TEXT NOT NULL DEFAULT 'pending'
|
|
276
|
-
);`);
|
|
277
|
-
ensureColumn(database, "outbox_quarantine", "envelope", "TEXT NOT NULL DEFAULT '{}'");
|
|
278
|
-
ensureColumn(database, "outbox_quarantine", "reason", "TEXT NOT NULL DEFAULT 'operator quarantine'");
|
|
279
|
-
ensureColumn(database, "outbox_quarantine", "quarantined_at", "INTEGER NOT NULL DEFAULT 0");
|
|
280
|
-
ensureColumn(database, "outbox_quarantine", "error_code", "TEXT NOT NULL DEFAULT 'UNKNOWN'");
|
|
281
|
-
ensureColumn(database, "outbox_quarantine", "details", "TEXT NOT NULL DEFAULT '{}'");
|
|
282
|
-
ensureColumn(database, "outbox_quarantine", "prior_state", "TEXT NOT NULL DEFAULT 'pending'");
|
|
283
|
-
database.exec("PRAGMA user_version = 4;");
|
|
284
|
-
}
|
|
285
|
-
if (version.user_version <= 4) {
|
|
286
|
-
database.transaction(() => {
|
|
287
|
-
database.exec(`CREATE TABLE IF NOT EXISTS task_operations (
|
|
247
|
+
CREATE TABLE outbox_quarantine (
|
|
248
|
+
envelope_id TEXT PRIMARY KEY,
|
|
249
|
+
envelope TEXT NOT NULL,
|
|
250
|
+
reason TEXT NOT NULL,
|
|
251
|
+
quarantined_at INTEGER NOT NULL,
|
|
252
|
+
error_code TEXT NOT NULL,
|
|
253
|
+
details TEXT NOT NULL,
|
|
254
|
+
prior_state TEXT NOT NULL
|
|
255
|
+
);
|
|
256
|
+
CREATE TABLE task_operations (
|
|
288
257
|
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
289
258
|
operation TEXT NOT NULL,
|
|
290
259
|
logical_id TEXT NOT NULL,
|
|
291
260
|
logical_type TEXT NOT NULL,
|
|
292
261
|
envelope_ids TEXT NOT NULL,
|
|
293
262
|
PRIMARY KEY (task_id, operation)
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function backfillTaskOperations(database: SqliteDatabase): void {
|
|
302
|
-
const envelopes = legacyEnvelopes(database);
|
|
303
|
-
const terminalTasks = new Set<string>();
|
|
304
|
-
const intents = database.query("SELECT intent_id, task_id, envelope_id FROM intents ORDER BY rowid").all() as LegacyIntentRow[];
|
|
305
|
-
for (const intent of intents) {
|
|
306
|
-
if (terminalTasks.has(intent.task_id)) continue;
|
|
307
|
-
const serializedEnvelope = envelopes.get(intent.envelope_id);
|
|
308
|
-
if (!serializedEnvelope) throw new Error(`legacy intent ${intent.intent_id} has no durable envelope`);
|
|
309
|
-
const envelope = legacyEnvelope(intent.envelope_id, serializedEnvelope);
|
|
310
|
-
const payload = legacyEnvelopePayload(envelope);
|
|
311
|
-
if (envelope.kind !== "intent" || envelope.taskId !== intent.task_id || !isRecord(payload) || payload.intentId !== intent.intent_id || payload.taskId !== intent.task_id || typeof payload.type !== "string") {
|
|
312
|
-
throw new Error(`legacy intent ${intent.intent_id} has malformed structured state`);
|
|
313
|
-
}
|
|
314
|
-
if (!isTerminalIntentType(payload.type)) continue;
|
|
315
|
-
insertTaskOperation(database, { taskId: intent.task_id, operation: TERMINAL_INTENT_OPERATION, logicalId: intent.intent_id, logicalType: payload.type, envelopeIds: [intent.envelope_id] });
|
|
316
|
-
terminalTasks.add(intent.task_id);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const acknowledgments = database.query("SELECT * FROM events WHERE type = 'task.parent_acknowledged' ORDER BY task_id, CAST(sequence AS INTEGER), rowid").all() as EventRow[];
|
|
320
|
-
const acknowledgedTasks = new Set<string>();
|
|
321
|
-
for (const acknowledgment of acknowledgments) {
|
|
322
|
-
if (acknowledgedTasks.has(acknowledgment.task_id)) continue;
|
|
323
|
-
const envelopeIds = [...envelopes]
|
|
324
|
-
.flatMap(([envelopeId, serializedEnvelope]) => {
|
|
325
|
-
const envelope = optionalLegacyEnvelope(envelopeId, serializedEnvelope);
|
|
326
|
-
return envelope?.kind === "canonical_event" && envelope.taskId === acknowledgment.task_id && eventIdFromLegacyEnvelope(envelope) === acknowledgment.event_id ? [envelopeId] : [];
|
|
327
|
-
})
|
|
328
|
-
.sort();
|
|
329
|
-
if (envelopeIds.length === 0) throw new Error(`legacy parent acknowledgment ${acknowledgment.event_id} has no durable envelope`);
|
|
330
|
-
insertTaskOperation(database, { taskId: acknowledgment.task_id, operation: PARENT_ACKNOWLEDGMENT_OPERATION, logicalId: acknowledgment.event_id, logicalType: acknowledgment.type, envelopeIds });
|
|
331
|
-
acknowledgedTasks.add(acknowledgment.task_id);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function legacyEnvelopes(database: SqliteDatabase): Map<string, string> {
|
|
336
|
-
const envelopes = new Map<string, string>();
|
|
337
|
-
const live = database.query("SELECT envelope_id, envelope FROM outbox ORDER BY rowid").all() as LegacyEnvelopeRow[];
|
|
338
|
-
const quarantined = database.query("SELECT envelope_id, envelope FROM outbox_quarantine ORDER BY rowid").all() as LegacyEnvelopeRow[];
|
|
339
|
-
for (const row of [...live, ...quarantined]) {
|
|
340
|
-
if (row.envelope_id.length === 0) throw new Error("legacy durable envelope identity is malformed");
|
|
341
|
-
if (!envelopes.has(row.envelope_id)) envelopes.set(row.envelope_id, row.envelope);
|
|
342
|
-
}
|
|
343
|
-
return envelopes;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function legacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope {
|
|
347
|
-
const envelope = parseEnvelope(serializedEnvelope);
|
|
348
|
-
if (envelope.envelopeId !== envelopeId) throw new Error(`legacy envelope ${envelopeId} identity is malformed`);
|
|
349
|
-
return envelope;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function optionalLegacyEnvelope(envelopeId: string, serializedEnvelope: string): RelayEnvelope | undefined {
|
|
353
|
-
try {
|
|
354
|
-
return legacyEnvelope(envelopeId, serializedEnvelope);
|
|
355
|
-
} catch {
|
|
356
|
-
// Unrelated operator quarantine rows are audit data, not lifecycle state.
|
|
357
|
-
return undefined;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function legacyEnvelopePayload(envelope: RelayEnvelope): unknown {
|
|
362
|
-
try {
|
|
363
|
-
return JSON.parse(envelope.payload) as unknown;
|
|
364
|
-
} catch {
|
|
365
|
-
throw new Error(`legacy envelope ${envelope.envelopeId} payload is malformed`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function eventIdFromLegacyEnvelope(envelope: RelayEnvelope): string | undefined {
|
|
370
|
-
const payload = legacyEnvelopePayload(envelope);
|
|
371
|
-
return isRecord(payload) && typeof payload.eventId === "string" ? payload.eventId : undefined;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function insertTaskOperation(database: SqliteDatabase, input: TaskOperationRecord): void {
|
|
375
|
-
database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
|
|
376
|
-
VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function ensureColumn(database: SqliteDatabase, table: string, name: string, definition: string): void {
|
|
380
|
-
const columns = database.query(`PRAGMA table_info(${table})`).all() as Array<{ readonly name: string }>;
|
|
381
|
-
if (!columns.some((column) => column.name === name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
263
|
+
);
|
|
264
|
+
CREATE INDEX tasks_origin_expiry ON tasks (origin_relay, origin_id, status, expires_at);
|
|
265
|
+
CREATE INDEX inbox_cursor ON inbox (cursor);
|
|
266
|
+
PRAGMA user_version = 5;
|
|
267
|
+
`);
|
|
268
|
+
})();
|
|
382
269
|
}
|
|
383
270
|
|
|
384
271
|
function preparePath(path: string): void {
|