@sgtbeatdown/pi-tasks 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -9
- package/package.json +6 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +51 -27
- package/src/extension.ts +236 -137
- package/src/gateway-client.ts +32 -8
- package/src/in-memory-task-relay.ts +114 -0
- package/src/index.ts +35 -0
- package/src/legacy-extension.ts +248 -0
- package/src/legacy-task-inbox.ts +148 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-core.ts +430 -0
- package/src/task-inbox.ts +68 -90
- package/src/task-protocol.ts +164 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +491 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +343 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
|
|
2
|
+
export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
|
|
3
|
+
export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
|
|
4
|
+
export const TERMINAL_INTENT_OPERATION = "terminal_intent";
|
|
5
|
+
|
|
6
|
+
export interface TaskEndpoint {
|
|
7
|
+
readonly relay: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A provider-defined reference which core resolves without interpreting. */
|
|
12
|
+
export interface RelayTargetReference {
|
|
13
|
+
readonly relay: string;
|
|
14
|
+
readonly reference: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RelayConnectInput {
|
|
18
|
+
readonly endpoint: TaskEndpoint;
|
|
19
|
+
readonly protocolVersion: string;
|
|
20
|
+
readonly receiveCursor: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RelayConnection {
|
|
24
|
+
readonly endpoint: TaskEndpoint;
|
|
25
|
+
readonly receiveCursor: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const TaskEnvelopeKind = {
|
|
29
|
+
assignment: "assignment",
|
|
30
|
+
intent: "intent",
|
|
31
|
+
canonicalEvent: "canonical_event",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type TaskEnvelopeKind = (typeof TaskEnvelopeKind)[keyof typeof TaskEnvelopeKind];
|
|
35
|
+
|
|
36
|
+
export interface RelayEnvelope {
|
|
37
|
+
readonly envelopeId: string;
|
|
38
|
+
readonly protocolVersion: string;
|
|
39
|
+
readonly source: TaskEndpoint;
|
|
40
|
+
readonly target: TaskEndpoint;
|
|
41
|
+
readonly taskId: string;
|
|
42
|
+
readonly kind: TaskEnvelopeKind;
|
|
43
|
+
/** Opaque to relays. The endpoint protocol defines its JSON representation. */
|
|
44
|
+
readonly payload: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RelayAcceptance {
|
|
48
|
+
readonly envelopeId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RelayReceiveRequest {
|
|
52
|
+
readonly endpoint: TaskEndpoint;
|
|
53
|
+
readonly cursor: string;
|
|
54
|
+
readonly limit: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RelayDelivery {
|
|
58
|
+
readonly cursor: string;
|
|
59
|
+
readonly envelope: RelayEnvelope;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RelayInboxPage {
|
|
63
|
+
readonly deliveries: readonly RelayDelivery[];
|
|
64
|
+
readonly nextCursor: string;
|
|
65
|
+
readonly hasMore: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RelayDeliveryAck {
|
|
69
|
+
readonly endpoint: TaskEndpoint;
|
|
70
|
+
readonly cursor: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TaskRelay {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
connect(input: RelayConnectInput, signal?: AbortSignal): Promise<RelayConnection>;
|
|
76
|
+
resolve(input: RelayTargetReference, signal?: AbortSignal): Promise<TaskEndpoint>;
|
|
77
|
+
send(input: RelayEnvelope, signal?: AbortSignal): Promise<RelayAcceptance>;
|
|
78
|
+
receive(input: RelayReceiveRequest, signal?: AbortSignal): Promise<RelayInboxPage>;
|
|
79
|
+
acknowledgeDelivery(input: RelayDeliveryAck, signal?: AbortSignal): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TaskEvent {
|
|
83
|
+
readonly eventId: string;
|
|
84
|
+
readonly taskId: string;
|
|
85
|
+
readonly type: string;
|
|
86
|
+
readonly sequence: string;
|
|
87
|
+
readonly source: TaskEndpoint;
|
|
88
|
+
readonly target: TaskEndpoint;
|
|
89
|
+
readonly occurredAt: number;
|
|
90
|
+
readonly payload: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TaskRecord {
|
|
94
|
+
readonly taskId: string;
|
|
95
|
+
readonly protocolVersion: string;
|
|
96
|
+
readonly origin: TaskEndpoint;
|
|
97
|
+
readonly target: TaskEndpoint;
|
|
98
|
+
readonly task: string;
|
|
99
|
+
readonly createdAt: number;
|
|
100
|
+
readonly expiresAt: number;
|
|
101
|
+
readonly status: "active" | "completed" | "failed" | "cancelled" | "timed_out";
|
|
102
|
+
readonly events: readonly TaskEvent[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type TerminalTaskIntentType = "task.completed" | "task.failed" | "task.cancelled";
|
|
106
|
+
|
|
107
|
+
export interface TaskIntent {
|
|
108
|
+
readonly intentId: string;
|
|
109
|
+
readonly taskId: string;
|
|
110
|
+
readonly type: TerminalTaskIntentType | "task.information" | "task.question" | "task.answer" | "task.delivery_receipt";
|
|
111
|
+
readonly payload: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface TerminalDeliveryIdentity {
|
|
115
|
+
readonly intentId: string;
|
|
116
|
+
readonly intentType: TerminalTaskIntentType;
|
|
117
|
+
readonly envelopeId: string;
|
|
118
|
+
readonly origin: TaskEndpoint;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface TerminalDeliveryPending extends TerminalDeliveryIdentity {
|
|
122
|
+
readonly state: "pending";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface TerminalDeliveryAccepted extends TerminalDeliveryIdentity {
|
|
126
|
+
readonly state: "accepted";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface TerminalDeliveryBlocked extends TerminalDeliveryIdentity {
|
|
130
|
+
readonly state: "delivery_blocked";
|
|
131
|
+
readonly blockedAt: number;
|
|
132
|
+
readonly error: { readonly code: string; readonly retryable: false; readonly details: Readonly<Record<string, unknown>> };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type TerminalDeliveryState =
|
|
136
|
+
| { readonly state: "not_submitted" }
|
|
137
|
+
| TerminalDeliveryPending
|
|
138
|
+
| TerminalDeliveryAccepted
|
|
139
|
+
| TerminalDeliveryBlocked;
|
|
140
|
+
|
|
141
|
+
export interface TaskSnapshot extends TaskRecord {
|
|
142
|
+
readonly terminalDelivery: TerminalDeliveryState;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class TaskProtocolError extends Error {
|
|
146
|
+
readonly code: string;
|
|
147
|
+
readonly retryable: boolean;
|
|
148
|
+
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
149
|
+
|
|
150
|
+
constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = "TaskProtocolError";
|
|
153
|
+
this.code = code;
|
|
154
|
+
this.retryable = options.retryable ?? true;
|
|
155
|
+
this.details = options.details;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class TaskOutboxDeliveryError extends TaskProtocolError {
|
|
160
|
+
constructor(code: string, message: string, options: { readonly retryable?: boolean; readonly details?: Readonly<Record<string, unknown>> } = {}) {
|
|
161
|
+
super(code, message, options);
|
|
162
|
+
this.name = "TaskOutboxDeliveryError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { TASK_PROTOCOL_VERSION } from "./task-protocol";
|
|
4
|
+
import type { TaskRelay } from "./task-protocol";
|
|
5
|
+
|
|
6
|
+
/** Shared assertions external relay adapters can run against their implementation. */
|
|
7
|
+
export function runTaskRelayConformance(createRelay: () => TaskRelay): void {
|
|
8
|
+
test("enforces registration, accepts duplicate envelopes once, and retains delivery until acknowledgement", async () => {
|
|
9
|
+
const relay = createRelay();
|
|
10
|
+
const source = { relay: relay.id, id: "source" };
|
|
11
|
+
const target = { relay: relay.id, id: "target" };
|
|
12
|
+
await relay.connect({ endpoint: source, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
13
|
+
await relay.connect({ endpoint: target, protocolVersion: TASK_PROTOCOL_VERSION, receiveCursor: "0" });
|
|
14
|
+
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: "{}" };
|
|
16
|
+
await relay.send(envelope);
|
|
17
|
+
await relay.send(envelope);
|
|
18
|
+
const page = await relay.receive({ endpoint: target, cursor: "0", limit: 10 });
|
|
19
|
+
expect(page.deliveries).toHaveLength(1);
|
|
20
|
+
await relay.acknowledgeDelivery({ endpoint: target, cursor: page.nextCursor });
|
|
21
|
+
expect((await relay.receive({ endpoint: target, cursor: page.nextCursor, limit: 10 })).deliveries).toHaveLength(0);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { openSqliteDatabase } from "./sqlite-database";
|
|
6
|
+
import type { SqliteDatabase } from "./sqlite-database";
|
|
7
|
+
import { PARENT_ACKNOWLEDGMENT_OPERATION, TERMINAL_INTENT_OPERATION } from "./task-protocol";
|
|
8
|
+
import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 5;
|
|
11
|
+
const OWNER_ONLY_MODE = 0o700;
|
|
12
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
13
|
+
const ENDPOINT_GENERATION_STATE_KEY = "endpoint_generation";
|
|
14
|
+
const ENDPOINT_BINDING_STATE_KEY = "endpoint_binding";
|
|
15
|
+
|
|
16
|
+
export interface TaskStoreOptions {
|
|
17
|
+
readonly path?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OutboxRecord {
|
|
21
|
+
readonly envelope: RelayEnvelope;
|
|
22
|
+
readonly state: "pending" | "accepted";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OutboxQuarantineInput {
|
|
26
|
+
readonly errorCode: string;
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
29
|
+
readonly quarantinedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OutboxQuarantineRecord extends OutboxQuarantineInput {
|
|
33
|
+
readonly envelope: RelayEnvelope;
|
|
34
|
+
readonly priorState: OutboxRecord["state"];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TaskOperationRecord {
|
|
38
|
+
readonly taskId: string;
|
|
39
|
+
readonly operation: string;
|
|
40
|
+
readonly logicalId: string;
|
|
41
|
+
readonly logicalType: string;
|
|
42
|
+
readonly envelopeIds: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TaskStore {
|
|
46
|
+
transaction<T>(operation: () => T): T;
|
|
47
|
+
putTask(task: Omit<TaskRecord, "events">): void;
|
|
48
|
+
getTask(taskId: string): TaskSnapshot | undefined;
|
|
49
|
+
listTasks(): readonly TaskSnapshot[];
|
|
50
|
+
setStatus(taskId: string, status: TaskRecord["status"]): void;
|
|
51
|
+
appendEvent(event: TaskEvent): boolean;
|
|
52
|
+
putOutbox(envelope: RelayEnvelope): void;
|
|
53
|
+
outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
|
|
54
|
+
markOutboxAccepted(envelopeId: string): void;
|
|
55
|
+
quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
|
|
56
|
+
quarantinedOutbox(): readonly OutboxQuarantineRecord[];
|
|
57
|
+
persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
|
|
58
|
+
getReceiveCursor(): string;
|
|
59
|
+
setReceiveCursor(cursor: string): void;
|
|
60
|
+
putIntent(intentId: string, taskId: string, envelopeId: string): void;
|
|
61
|
+
putInsertionReceipt(taskId: string, eventId: string): boolean;
|
|
62
|
+
reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
|
|
63
|
+
getEndpointGeneration(): string | undefined;
|
|
64
|
+
setEndpointGeneration(generation: string): void;
|
|
65
|
+
getEndpointBinding(): TaskEndpoint | undefined;
|
|
66
|
+
setEndpointBinding(endpoint: TaskEndpoint): void;
|
|
67
|
+
close(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
|
|
71
|
+
const path = options.path ?? join(homedir(), ".pi", "tasks", "v2", "tasks.sqlite");
|
|
72
|
+
preparePath(path);
|
|
73
|
+
const database = openSqliteDatabase(path);
|
|
74
|
+
database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
75
|
+
migrate(database);
|
|
76
|
+
|
|
77
|
+
const store: TaskStore = {
|
|
78
|
+
transaction(operation) {
|
|
79
|
+
return database.transaction(operation)();
|
|
80
|
+
},
|
|
81
|
+
putTask(task) {
|
|
82
|
+
database.query(`INSERT INTO tasks (task_id, protocol_version, origin_relay, origin_id, target_relay, target_id, task, created_at, expires_at, status)
|
|
83
|
+
VALUES ($taskId, $protocolVersion, $originRelay, $originId, $targetRelay, $targetId, $task, $createdAt, $expiresAt, $status)
|
|
84
|
+
ON CONFLICT(task_id) DO NOTHING`).run(taskRow(task));
|
|
85
|
+
},
|
|
86
|
+
getTask(taskId) {
|
|
87
|
+
const row = database.query("SELECT * FROM tasks WHERE task_id = ?").get(taskId) as TaskRow | null;
|
|
88
|
+
if (!row) return undefined;
|
|
89
|
+
const eventRows = database.query("SELECT * FROM events WHERE task_id = ? ORDER BY CAST(sequence AS INTEGER), event_id").all(taskId) as EventRow[];
|
|
90
|
+
return taskFromRow(database, row, eventRows);
|
|
91
|
+
},
|
|
92
|
+
listTasks() {
|
|
93
|
+
const rows = database.query("SELECT * FROM tasks ORDER BY created_at, task_id").all() as TaskRow[];
|
|
94
|
+
const events = database.query("SELECT * FROM events ORDER BY CAST(sequence AS INTEGER), event_id").all() as EventRow[];
|
|
95
|
+
const eventsByTask = new Map<string, EventRow[]>();
|
|
96
|
+
for (const event of events) {
|
|
97
|
+
const current = eventsByTask.get(event.task_id) ?? [];
|
|
98
|
+
current.push(event);
|
|
99
|
+
eventsByTask.set(event.task_id, current);
|
|
100
|
+
}
|
|
101
|
+
return rows.map((row) => taskFromRow(database, row, eventsByTask.get(row.task_id) ?? []));
|
|
102
|
+
},
|
|
103
|
+
setStatus(taskId, status) {
|
|
104
|
+
database.query("UPDATE tasks SET status = ? WHERE task_id = ?").run(status, taskId);
|
|
105
|
+
},
|
|
106
|
+
appendEvent(event) {
|
|
107
|
+
const result = database.query(`INSERT INTO events (event_id, task_id, type, sequence, source_relay, source_id, target_relay, target_id, occurred_at, payload)
|
|
108
|
+
VALUES ($eventId, $taskId, $type, $sequence, $sourceRelay, $sourceId, $targetRelay, $targetId, $occurredAt, $payload)
|
|
109
|
+
ON CONFLICT(event_id) DO NOTHING`).run(eventRow(event));
|
|
110
|
+
return result.changes === 1;
|
|
111
|
+
},
|
|
112
|
+
putOutbox(envelope) {
|
|
113
|
+
database.query("INSERT INTO outbox (envelope_id, envelope, state) VALUES (?, ?, 'pending') ON CONFLICT(envelope_id) DO NOTHING").run(envelope.envelopeId, JSON.stringify(envelope));
|
|
114
|
+
},
|
|
115
|
+
outbox(state) {
|
|
116
|
+
const rows = database.query("SELECT envelope FROM outbox WHERE state = ? ORDER BY rowid").all(state) as Array<{ readonly envelope: string }>;
|
|
117
|
+
return rows.map((row) => ({ envelope: parseEnvelope(row.envelope), state }));
|
|
118
|
+
},
|
|
119
|
+
markOutboxAccepted(envelopeId) {
|
|
120
|
+
database.query("UPDATE outbox SET state = 'accepted' WHERE envelope_id = ?").run(envelopeId);
|
|
121
|
+
},
|
|
122
|
+
quarantineOutbox(envelopeId, input) {
|
|
123
|
+
const row = database.query("SELECT envelope, state FROM outbox WHERE envelope_id = ?").get(envelopeId) as { readonly envelope: string; readonly state: OutboxRecord["state"] } | null;
|
|
124
|
+
if (!row) return;
|
|
125
|
+
database.query(`INSERT INTO outbox_quarantine (envelope, error_code, reason, details, quarantined_at, prior_state, envelope_id)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING`).run(
|
|
127
|
+
row.envelope, input.errorCode, input.reason, JSON.stringify(input.details), input.quarantinedAt, row.state, envelopeId,
|
|
128
|
+
);
|
|
129
|
+
database.query("DELETE FROM outbox WHERE envelope_id = ?").run(envelopeId);
|
|
130
|
+
},
|
|
131
|
+
quarantinedOutbox() {
|
|
132
|
+
const rows = database.query("SELECT envelope, error_code, reason, details, quarantined_at, prior_state FROM outbox_quarantine ORDER BY rowid").all() as OutboxQuarantineRow[];
|
|
133
|
+
return rows.map((row) => ({
|
|
134
|
+
envelope: parseEnvelope(row.envelope),
|
|
135
|
+
errorCode: row.error_code,
|
|
136
|
+
reason: row.reason,
|
|
137
|
+
details: parseRecord(row.details),
|
|
138
|
+
quarantinedAt: row.quarantined_at,
|
|
139
|
+
priorState: row.prior_state,
|
|
140
|
+
}));
|
|
141
|
+
},
|
|
142
|
+
persistInbox(envelope, cursor) {
|
|
143
|
+
const result = database.query("INSERT INTO inbox (envelope_id, cursor, envelope) VALUES (?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING").run(envelope.envelopeId, cursor, JSON.stringify(envelope));
|
|
144
|
+
return result.changes === 1;
|
|
145
|
+
},
|
|
146
|
+
getReceiveCursor() {
|
|
147
|
+
const row = database.query("SELECT value FROM relay_state WHERE name = 'receive_cursor'").get() as { readonly value: string } | null;
|
|
148
|
+
return row?.value ?? "0";
|
|
149
|
+
},
|
|
150
|
+
setReceiveCursor(cursor) {
|
|
151
|
+
database.query("INSERT INTO relay_state (name, value) VALUES ('receive_cursor', ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(cursor);
|
|
152
|
+
},
|
|
153
|
+
putIntent(intentId, taskId, envelopeId) {
|
|
154
|
+
database.query("INSERT INTO intents (intent_id, task_id, envelope_id) VALUES (?, ?, ?) ON CONFLICT(intent_id) DO NOTHING").run(intentId, taskId, envelopeId);
|
|
155
|
+
},
|
|
156
|
+
putInsertionReceipt(taskId, eventId) {
|
|
157
|
+
const result = database.query("INSERT INTO insertion_receipts (task_id, event_id) VALUES (?, ?) ON CONFLICT(task_id, event_id) DO NOTHING").run(taskId, eventId);
|
|
158
|
+
return result.changes === 1;
|
|
159
|
+
},
|
|
160
|
+
reserveTaskOperation(input) {
|
|
161
|
+
const result = database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
|
|
162
|
+
VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
|
|
163
|
+
const row = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(input.taskId, input.operation) as TaskOperationRow | null;
|
|
164
|
+
if (!row) throw new Error("task operation reservation was not persisted");
|
|
165
|
+
return { created: result.changes === 1, record: taskOperationFromRow(row) };
|
|
166
|
+
},
|
|
167
|
+
getEndpointGeneration() {
|
|
168
|
+
const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_GENERATION_STATE_KEY) as { readonly value: string } | null;
|
|
169
|
+
return row?.value;
|
|
170
|
+
},
|
|
171
|
+
setEndpointGeneration(generation) {
|
|
172
|
+
database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_GENERATION_STATE_KEY, generation);
|
|
173
|
+
},
|
|
174
|
+
getEndpointBinding() {
|
|
175
|
+
const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_BINDING_STATE_KEY) as { readonly value: string } | null;
|
|
176
|
+
return row ? parseEndpoint(row.value) : undefined;
|
|
177
|
+
},
|
|
178
|
+
setEndpointBinding(endpoint) {
|
|
179
|
+
database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_BINDING_STATE_KEY, JSON.stringify(endpoint));
|
|
180
|
+
},
|
|
181
|
+
close() { database.close(); },
|
|
182
|
+
};
|
|
183
|
+
return store;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface TaskRow {
|
|
187
|
+
readonly task_id: string;
|
|
188
|
+
readonly protocol_version: string;
|
|
189
|
+
readonly origin_relay: string;
|
|
190
|
+
readonly origin_id: string;
|
|
191
|
+
readonly target_relay: string;
|
|
192
|
+
readonly target_id: string;
|
|
193
|
+
readonly task: string;
|
|
194
|
+
readonly created_at: number;
|
|
195
|
+
readonly expires_at: number;
|
|
196
|
+
readonly status: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface OutboxQuarantineRow {
|
|
200
|
+
readonly envelope: string;
|
|
201
|
+
readonly error_code: string;
|
|
202
|
+
readonly reason: string;
|
|
203
|
+
readonly details: string;
|
|
204
|
+
readonly quarantined_at: number;
|
|
205
|
+
readonly prior_state: OutboxRecord["state"];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface TaskOperationRow {
|
|
209
|
+
readonly task_id: string;
|
|
210
|
+
readonly operation: string;
|
|
211
|
+
readonly logical_id: string;
|
|
212
|
+
readonly logical_type: string;
|
|
213
|
+
readonly envelope_ids: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
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
|
+
interface EventRow {
|
|
228
|
+
readonly event_id: string;
|
|
229
|
+
readonly task_id: string;
|
|
230
|
+
readonly type: string;
|
|
231
|
+
readonly sequence: string;
|
|
232
|
+
readonly source_relay: string;
|
|
233
|
+
readonly source_id: string;
|
|
234
|
+
readonly target_relay: string;
|
|
235
|
+
readonly target_id: string;
|
|
236
|
+
readonly occurred_at: number;
|
|
237
|
+
readonly payload: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function migrate(database: SqliteDatabase): void {
|
|
241
|
+
const version = database.query("PRAGMA user_version").get() as { readonly user_version: number };
|
|
242
|
+
if (version.user_version > SCHEMA_VERSION) throw new Error("task store schema is newer than this pi-tasks version");
|
|
243
|
+
if (version.user_version === 0) {
|
|
244
|
+
database.exec(`
|
|
245
|
+
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
|
+
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);
|
|
247
|
+
CREATE TABLE outbox (envelope_id TEXT PRIMARY KEY, envelope TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending', 'accepted')));
|
|
248
|
+
CREATE TABLE inbox (envelope_id TEXT PRIMARY KEY, cursor TEXT NOT NULL, envelope TEXT NOT NULL);
|
|
249
|
+
CREATE TABLE intents (intent_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, envelope_id TEXT NOT NULL);
|
|
250
|
+
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
|
+
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
|
+
PRAGMA user_version = 3;
|
|
265
|
+
`);
|
|
266
|
+
}
|
|
267
|
+
if (version.user_version <= 3) {
|
|
268
|
+
database.exec(`CREATE TABLE IF NOT EXISTS outbox_quarantine (
|
|
269
|
+
envelope_id TEXT PRIMARY KEY,
|
|
270
|
+
envelope TEXT NOT NULL,
|
|
271
|
+
reason TEXT NOT NULL,
|
|
272
|
+
quarantined_at INTEGER NOT NULL,
|
|
273
|
+
error_code TEXT NOT NULL DEFAULT 'UNKNOWN',
|
|
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 (
|
|
288
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
289
|
+
operation TEXT NOT NULL,
|
|
290
|
+
logical_id TEXT NOT NULL,
|
|
291
|
+
logical_type TEXT NOT NULL,
|
|
292
|
+
envelope_ids TEXT NOT NULL,
|
|
293
|
+
PRIMARY KEY (task_id, operation)
|
|
294
|
+
);`);
|
|
295
|
+
backfillTaskOperations(database);
|
|
296
|
+
database.exec("PRAGMA user_version = 5;");
|
|
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}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function preparePath(path: string): void {
|
|
385
|
+
if (path === ":memory:") return;
|
|
386
|
+
const directory = dirname(path);
|
|
387
|
+
mkdirSync(directory, { recursive: true, mode: OWNER_ONLY_MODE });
|
|
388
|
+
const directoryMode = statSync(directory).mode & 0o777;
|
|
389
|
+
if ((directoryMode & 0o077) !== 0) throw new Error(`task store directory is not owner-only: ${directory}`);
|
|
390
|
+
if (existsSync(path)) {
|
|
391
|
+
const fileMode = statSync(path).mode & 0o777;
|
|
392
|
+
if ((fileMode & 0o077) !== 0) throw new Error(`task store file is not owner-only: ${path}`);
|
|
393
|
+
} else {
|
|
394
|
+
writeFileSync(path, "", { mode: OWNER_ONLY_FILE_MODE });
|
|
395
|
+
chmodSync(path, OWNER_ONLY_FILE_MODE);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function taskRow(task: Omit<TaskRecord, "events">): Record<string, string | number> {
|
|
400
|
+
return {
|
|
401
|
+
taskId: task.taskId, protocolVersion: task.protocolVersion, originRelay: task.origin.relay, originId: task.origin.id,
|
|
402
|
+
targetRelay: task.target.relay, targetId: task.target.id, task: task.task, createdAt: task.createdAt, expiresAt: task.expiresAt, status: task.status,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function taskFromRow(database: SqliteDatabase, row: TaskRow, events: readonly EventRow[]): TaskSnapshot {
|
|
407
|
+
return {
|
|
408
|
+
taskId: row.task_id,
|
|
409
|
+
protocolVersion: row.protocol_version,
|
|
410
|
+
origin: { relay: row.origin_relay, id: row.origin_id },
|
|
411
|
+
target: { relay: row.target_relay, id: row.target_id },
|
|
412
|
+
task: row.task,
|
|
413
|
+
createdAt: row.created_at,
|
|
414
|
+
expiresAt: row.expires_at,
|
|
415
|
+
status: row.status as TaskRecord["status"],
|
|
416
|
+
events: events.map(eventFromRow),
|
|
417
|
+
terminalDelivery: terminalDeliveryFromDatabase(database, row),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function eventRow(event: TaskEvent): Record<string, string | number> {
|
|
422
|
+
return {
|
|
423
|
+
eventId: event.eventId, taskId: event.taskId, type: event.type, sequence: event.sequence,
|
|
424
|
+
sourceRelay: event.source.relay, sourceId: event.source.id, targetRelay: event.target.relay, targetId: event.target.id,
|
|
425
|
+
occurredAt: event.occurredAt, payload: JSON.stringify(event.payload),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function eventFromRow(row: EventRow): TaskEvent {
|
|
430
|
+
return {
|
|
431
|
+
eventId: row.event_id, taskId: row.task_id, type: row.type, sequence: row.sequence,
|
|
432
|
+
source: { relay: row.source_relay, id: row.source_id }, target: { relay: row.target_relay, id: row.target_id },
|
|
433
|
+
occurredAt: row.occurred_at, payload: parseRecord(row.payload),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseEnvelope(value: string): RelayEnvelope {
|
|
438
|
+
return JSON.parse(value) as RelayEnvelope;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function taskOperationFromRow(row: TaskOperationRow): TaskOperationRecord {
|
|
442
|
+
const envelopeIds = JSON.parse(row.envelope_ids) as unknown;
|
|
443
|
+
if (!Array.isArray(envelopeIds) || !envelopeIds.every((value) => typeof value === "string" && value.length > 0)) throw new Error("task operation envelope identities are malformed");
|
|
444
|
+
return { taskId: row.task_id, operation: row.operation, logicalId: row.logical_id, logicalType: row.logical_type, envelopeIds };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function terminalDeliveryFromDatabase(database: SqliteDatabase, task: TaskRow): TerminalDeliveryState {
|
|
448
|
+
const operation = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(task.task_id, TERMINAL_INTENT_OPERATION) as TaskOperationRow | null;
|
|
449
|
+
if (!operation) return { state: "not_submitted" };
|
|
450
|
+
const record = taskOperationFromRow(operation);
|
|
451
|
+
if (!isTerminalIntentType(record.logicalType) || record.envelopeIds.length !== 1) throw new Error("terminal task operation is malformed");
|
|
452
|
+
const envelopeId = record.envelopeIds[0];
|
|
453
|
+
if (envelopeId === undefined) throw new Error("terminal task operation has no envelope identity");
|
|
454
|
+
const identity = {
|
|
455
|
+
intentId: record.logicalId,
|
|
456
|
+
intentType: record.logicalType,
|
|
457
|
+
envelopeId,
|
|
458
|
+
origin: { relay: task.origin_relay, id: task.origin_id },
|
|
459
|
+
};
|
|
460
|
+
const outbox = database.query("SELECT state FROM outbox WHERE envelope_id = ?").get(identity.envelopeId) as { readonly state: OutboxRecord["state"] } | null;
|
|
461
|
+
if (outbox?.state === "pending") return { state: "pending", ...identity };
|
|
462
|
+
if (outbox?.state === "accepted") return { state: "accepted", ...identity };
|
|
463
|
+
const blocked = database.query("SELECT error_code, details, quarantined_at FROM outbox_quarantine WHERE envelope_id = ?").get(identity.envelopeId) as Pick<OutboxQuarantineRow, "error_code" | "details" | "quarantined_at"> | null;
|
|
464
|
+
if (!blocked) throw new Error("terminal task operation has no durable delivery record");
|
|
465
|
+
return {
|
|
466
|
+
state: "delivery_blocked",
|
|
467
|
+
...identity,
|
|
468
|
+
blockedAt: blocked.quarantined_at,
|
|
469
|
+
error: { code: blocked.error_code, retryable: false, details: parseRecord(blocked.details) },
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isTerminalIntentType(value: string): value is TerminalTaskIntentType {
|
|
474
|
+
return value === "task.completed" || value === "task.failed" || value === "task.cancelled";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parseEndpoint(value: string): TaskEndpoint {
|
|
478
|
+
const endpoint = JSON.parse(value) as unknown;
|
|
479
|
+
if (!isRecord(endpoint) || typeof endpoint.relay !== "string" || endpoint.relay.length === 0 || typeof endpoint.id !== "string" || endpoint.id.length === 0) {
|
|
480
|
+
throw new Error("task store endpoint binding is malformed");
|
|
481
|
+
}
|
|
482
|
+
return { relay: endpoint.relay, id: endpoint.id };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseRecord(value: string): Record<string, unknown> {
|
|
486
|
+
return JSON.parse(value) as Record<string, unknown>;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
490
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
491
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, registerAgentTaskTools } from "./legacy-extension";
|