ompclaw 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +152 -0
- package/SECURITY.md +61 -0
- package/config.example.json +45 -0
- package/docs/guide.md +360 -0
- package/docs/rpc-service.md +240 -0
- package/package.json +93 -0
- package/src/api.ts +556 -0
- package/src/gateway-app.ts +393 -0
- package/src/gateway-config.ts +379 -0
- package/src/gateway-core.ts +410 -0
- package/src/gateway-scheduler.ts +425 -0
- package/src/gateway-store.ts +947 -0
- package/src/gateway-tools.ts +443 -0
- package/src/gateway-types.ts +290 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +13 -0
- package/src/markdown.ts +156 -0
- package/src/outbound.ts +353 -0
- package/src/rpc-cli.ts +408 -0
- package/src/rpc-client.ts +308 -0
- package/src/rpc-config.ts +70 -0
- package/src/rpc-profile.ts +215 -0
- package/src/rpc-protocol.ts +326 -0
- package/src/rpc-runtime.ts +875 -0
- package/src/rpc-service.ts +191 -0
- package/src/rpc-ui.ts +218 -0
- package/src/transports/telegram/adapter.ts +829 -0
- package/src/transports/websocket/adapter.ts +704 -0
- package/src/transports/websocket/protocol.ts +256 -0
- package/src/type-guards.ts +4 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import type { ConversationAddress, Principal, TransportIdentity } from "./gateway-types";
|
|
5
|
+
import { isRecord } from "./type-guards";
|
|
6
|
+
|
|
7
|
+
export type JsonPrimitive = boolean | number | string | null;
|
|
8
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
9
|
+
|
|
10
|
+
export interface ConversationBinding {
|
|
11
|
+
readonly address: ConversationAddress;
|
|
12
|
+
readonly ompSessionPath: string;
|
|
13
|
+
readonly workspace: JsonValue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PendingInteraction {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly address: ConversationAddress;
|
|
19
|
+
readonly kind: string;
|
|
20
|
+
readonly payload: JsonValue;
|
|
21
|
+
readonly createdAt: number;
|
|
22
|
+
readonly expiresAt?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ScheduledJobSchedule =
|
|
26
|
+
| { readonly kind: "at"; readonly at: number }
|
|
27
|
+
| { readonly kind: "cron"; readonly expression: string; readonly timezone?: string | undefined };
|
|
28
|
+
|
|
29
|
+
export interface ScheduledJob {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly principalId: string;
|
|
32
|
+
readonly identity: TransportIdentity;
|
|
33
|
+
readonly address: ConversationAddress;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly prompt: string;
|
|
36
|
+
readonly schedule: ScheduledJobSchedule;
|
|
37
|
+
readonly enabled: boolean;
|
|
38
|
+
readonly nextRunAt?: number | undefined;
|
|
39
|
+
readonly retryAt?: number | undefined;
|
|
40
|
+
readonly attemptCount: number;
|
|
41
|
+
readonly successCount: number;
|
|
42
|
+
readonly failureCount: number;
|
|
43
|
+
readonly createdAt: number;
|
|
44
|
+
readonly updatedAt: number;
|
|
45
|
+
readonly lastRunAt?: number | undefined;
|
|
46
|
+
readonly lastSuccessAt?: number | undefined;
|
|
47
|
+
readonly lastError?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LegacyTelegramStateImportOptions {
|
|
51
|
+
readonly accessPath: string;
|
|
52
|
+
readonly rpcStatePath: string;
|
|
53
|
+
readonly workspace: JsonValue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LegacyTelegramStateImportResult {
|
|
57
|
+
readonly imported: boolean;
|
|
58
|
+
readonly principal?: Principal;
|
|
59
|
+
readonly binding?: ConversationBinding;
|
|
60
|
+
readonly checkpointImported: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const LEGACY_TELEGRAM_STATE_MIGRATION = "legacy-telegram-state-v1";
|
|
64
|
+
|
|
65
|
+
type SqlRow = Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
69
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
|
70
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
71
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
72
|
+
if (!isRecord(value)) return false;
|
|
73
|
+
|
|
74
|
+
const prototype = Object.getPrototypeOf(value);
|
|
75
|
+
return (prototype === Object.prototype || prototype === null) && Object.values(value).every(isJsonValue);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function encodeJson(value: JsonValue, context: string): string {
|
|
79
|
+
if (!isJsonValue(value)) throw new Error(`${context} must be a JSON value`);
|
|
80
|
+
const encoded = JSON.stringify(value);
|
|
81
|
+
if (typeof encoded !== "string") throw new Error(`${context} must be a JSON value`);
|
|
82
|
+
return encoded;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function decodeJson(raw: unknown, context: string): JsonValue {
|
|
86
|
+
if (typeof raw !== "string") throw new Error(`corrupt JSON stored for ${context}`);
|
|
87
|
+
|
|
88
|
+
let decoded: unknown;
|
|
89
|
+
try {
|
|
90
|
+
decoded = JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`corrupt JSON stored for ${context}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isJsonValue(decoded)) throw new Error(`corrupt JSON stored for ${context}`);
|
|
96
|
+
return decoded;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function storedString(row: SqlRow, column: string, context: string): string {
|
|
100
|
+
const value = row[column];
|
|
101
|
+
if (typeof value !== "string") throw new Error(`corrupt stored ${context}: ${column} is not text`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function storedTimestamp(row: SqlRow, column: string, context: string): number {
|
|
106
|
+
const value = row[column];
|
|
107
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
108
|
+
throw new Error(`corrupt stored ${context}: ${column} is not an integer timestamp`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requiredText(value: unknown, context: string): void {
|
|
114
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${context} must not be empty`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateIdentity(identity: TransportIdentity): void {
|
|
118
|
+
requiredText(identity.transport, "transport identity transport");
|
|
119
|
+
requiredText(identity.account, "transport identity account");
|
|
120
|
+
requiredText(identity.subject, "transport identity subject");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validateAddress(address: ConversationAddress): void {
|
|
124
|
+
requiredText(address.transport, "conversation address transport");
|
|
125
|
+
requiredText(address.account, "conversation address account");
|
|
126
|
+
requiredText(address.channel, "conversation address channel");
|
|
127
|
+
if (address.thread !== undefined) requiredText(address.thread, "conversation address thread");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function databasePath(path: string): string {
|
|
131
|
+
requiredText(path, "database path");
|
|
132
|
+
return resolve(path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createPrivateDatabasePath(path: string): void {
|
|
136
|
+
const parent = dirname(path);
|
|
137
|
+
const parentExisted = existsSync(parent);
|
|
138
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
139
|
+
|
|
140
|
+
// POSIX mode bits are not meaningful on Windows. Do not mutate an existing
|
|
141
|
+
// parent directory, which may legitimately be shared with another app.
|
|
142
|
+
if (!parentExisted && process.platform !== "win32") chmodSync(parent, 0o700);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function restrictNewDatabase(path: string): void {
|
|
146
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function decodePrincipal(row: SqlRow): Principal {
|
|
150
|
+
const roles = decodeJson(row.roles_json, "principal roles");
|
|
151
|
+
if (!Array.isArray(roles) || !roles.every((role) => typeof role === "string")) {
|
|
152
|
+
throw new Error("corrupt JSON stored for principal roles");
|
|
153
|
+
}
|
|
154
|
+
return { id: storedString(row, "id", "principal"), roles };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function decodeConversationBinding(row: SqlRow): ConversationBinding {
|
|
158
|
+
const thread = storedString(row, "thread", "conversation binding");
|
|
159
|
+
return {
|
|
160
|
+
address: {
|
|
161
|
+
transport: storedString(row, "transport", "conversation binding"),
|
|
162
|
+
account: storedString(row, "account", "conversation binding"),
|
|
163
|
+
channel: storedString(row, "channel", "conversation binding"),
|
|
164
|
+
...(thread === "" ? {} : { thread }),
|
|
165
|
+
},
|
|
166
|
+
ompSessionPath: storedString(row, "omp_session_path", "conversation binding"),
|
|
167
|
+
workspace: decodeJson(row.workspace_json, "conversation binding workspace"),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function decodePendingInteraction(row: SqlRow): PendingInteraction {
|
|
172
|
+
const expiresAt = row.expires_at;
|
|
173
|
+
if (expiresAt !== null && (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt))) {
|
|
174
|
+
throw new Error("corrupt stored pending interaction: expires_at is not an integer timestamp");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const thread = storedString(row, "thread", "pending interaction");
|
|
178
|
+
return {
|
|
179
|
+
id: storedString(row, "id", "pending interaction"),
|
|
180
|
+
address: {
|
|
181
|
+
transport: storedString(row, "transport", "pending interaction"),
|
|
182
|
+
account: storedString(row, "account", "pending interaction"),
|
|
183
|
+
channel: storedString(row, "channel", "pending interaction"),
|
|
184
|
+
...(thread === "" ? {} : { thread }),
|
|
185
|
+
},
|
|
186
|
+
kind: storedString(row, "kind", "pending interaction"),
|
|
187
|
+
payload: decodeJson(row.payload_json, "pending interaction payload"),
|
|
188
|
+
createdAt: storedTimestamp(row, "created_at", "pending interaction"),
|
|
189
|
+
...(expiresAt === null ? {} : { expiresAt }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function optionalStoredTimestamp(row: SqlRow, column: string, context: string): number | undefined {
|
|
194
|
+
const value = row[column];
|
|
195
|
+
if (value === null) return undefined;
|
|
196
|
+
return storedTimestamp(row, column, context);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function storedCount(row: SqlRow, column: string, context: string): number {
|
|
200
|
+
const value = storedTimestamp(row, column, context);
|
|
201
|
+
if (value < 0) throw new Error(`corrupt stored ${context}: ${column} is negative`);
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function decodeScheduledJob(row: SqlRow): ScheduledJob {
|
|
206
|
+
const context = "scheduled job";
|
|
207
|
+
const thread = storedString(row, "thread", context);
|
|
208
|
+
const schedule = decodeJson(row.schedule_json, `${context} schedule`);
|
|
209
|
+
if (!isRecord(schedule) || (schedule.kind !== "at" && schedule.kind !== "cron")) {
|
|
210
|
+
throw new Error("corrupt JSON stored for scheduled job schedule");
|
|
211
|
+
}
|
|
212
|
+
if (schedule.kind === "at") {
|
|
213
|
+
if (typeof schedule.at !== "number" || !Number.isSafeInteger(schedule.at)) {
|
|
214
|
+
throw new Error("corrupt JSON stored for scheduled job schedule");
|
|
215
|
+
}
|
|
216
|
+
} else if (
|
|
217
|
+
typeof schedule.expression !== "string" ||
|
|
218
|
+
schedule.expression.length === 0 ||
|
|
219
|
+
(schedule.timezone !== undefined && typeof schedule.timezone !== "string")
|
|
220
|
+
) {
|
|
221
|
+
throw new Error("corrupt JSON stored for scheduled job schedule");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const enabled = row.enabled;
|
|
225
|
+
if (enabled !== 0 && enabled !== 1) throw new Error("corrupt stored scheduled job: enabled is not boolean");
|
|
226
|
+
const lastError = row.last_error;
|
|
227
|
+
if (lastError !== null && typeof lastError !== "string") {
|
|
228
|
+
throw new Error("corrupt stored scheduled job: last_error is not text");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
id: storedString(row, "id", context),
|
|
233
|
+
principalId: storedString(row, "principal_id", context),
|
|
234
|
+
identity: {
|
|
235
|
+
transport: storedString(row, "transport", context),
|
|
236
|
+
account: storedString(row, "account", context),
|
|
237
|
+
subject: storedString(row, "subject", context),
|
|
238
|
+
},
|
|
239
|
+
address: {
|
|
240
|
+
transport: storedString(row, "transport", context),
|
|
241
|
+
account: storedString(row, "account", context),
|
|
242
|
+
channel: storedString(row, "channel", context),
|
|
243
|
+
...(thread === "" ? {} : { thread }),
|
|
244
|
+
},
|
|
245
|
+
name: storedString(row, "name", context),
|
|
246
|
+
prompt: storedString(row, "prompt", context),
|
|
247
|
+
schedule: schedule as unknown as ScheduledJobSchedule,
|
|
248
|
+
enabled: enabled === 1,
|
|
249
|
+
...(optionalStoredTimestamp(row, "next_run_at", context) === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { nextRunAt: optionalStoredTimestamp(row, "next_run_at", context) }),
|
|
252
|
+
...(optionalStoredTimestamp(row, "retry_at", context) === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: { retryAt: optionalStoredTimestamp(row, "retry_at", context) }),
|
|
255
|
+
attemptCount: storedCount(row, "attempt_count", context),
|
|
256
|
+
successCount: storedCount(row, "success_count", context),
|
|
257
|
+
failureCount: storedCount(row, "failure_count", context),
|
|
258
|
+
createdAt: storedTimestamp(row, "created_at", context),
|
|
259
|
+
updatedAt: storedTimestamp(row, "updated_at", context),
|
|
260
|
+
...(optionalStoredTimestamp(row, "last_run_at", context) === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { lastRunAt: optionalStoredTimestamp(row, "last_run_at", context) }),
|
|
263
|
+
...(optionalStoredTimestamp(row, "last_success_at", context) === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: { lastSuccessAt: optionalStoredTimestamp(row, "last_success_at", context) }),
|
|
266
|
+
...(lastError === null ? {} : { lastError }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const SCHEDULED_JOB_FIELDS = [
|
|
271
|
+
"id",
|
|
272
|
+
"principal_id",
|
|
273
|
+
"transport",
|
|
274
|
+
"account",
|
|
275
|
+
"subject",
|
|
276
|
+
"channel",
|
|
277
|
+
"thread",
|
|
278
|
+
"name",
|
|
279
|
+
"prompt",
|
|
280
|
+
"schedule_json",
|
|
281
|
+
"enabled",
|
|
282
|
+
"next_run_at",
|
|
283
|
+
"retry_at",
|
|
284
|
+
"attempt_count",
|
|
285
|
+
"success_count",
|
|
286
|
+
"failure_count",
|
|
287
|
+
"created_at",
|
|
288
|
+
"updated_at",
|
|
289
|
+
"last_run_at",
|
|
290
|
+
"last_success_at",
|
|
291
|
+
"last_error",
|
|
292
|
+
].join(", ");
|
|
293
|
+
|
|
294
|
+
function validateScheduledJob(job: ScheduledJob): void {
|
|
295
|
+
requiredText(job.id, "scheduled job id");
|
|
296
|
+
requiredText(job.principalId, "scheduled job principal");
|
|
297
|
+
validateIdentity(job.identity);
|
|
298
|
+
validateAddress(job.address);
|
|
299
|
+
if (job.identity.transport !== job.address.transport || job.identity.account !== job.address.account) {
|
|
300
|
+
throw new Error("scheduled job identity and address must use the same transport account");
|
|
301
|
+
}
|
|
302
|
+
requiredText(job.name, "scheduled job name");
|
|
303
|
+
requiredText(job.prompt, "scheduled job prompt");
|
|
304
|
+
if (job.schedule.kind === "at") {
|
|
305
|
+
if (!Number.isSafeInteger(job.schedule.at)) throw new Error("scheduled job time must be an integer timestamp");
|
|
306
|
+
} else {
|
|
307
|
+
requiredText(job.schedule.expression, "scheduled job cron expression");
|
|
308
|
+
if (job.schedule.timezone !== undefined) requiredText(job.schedule.timezone, "scheduled job timezone");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const [label, value] of [
|
|
312
|
+
["next run", job.nextRunAt],
|
|
313
|
+
["retry", job.retryAt],
|
|
314
|
+
["created", job.createdAt],
|
|
315
|
+
["updated", job.updatedAt],
|
|
316
|
+
["last run", job.lastRunAt],
|
|
317
|
+
["last success", job.lastSuccessAt],
|
|
318
|
+
] as const) {
|
|
319
|
+
if (value !== undefined && !Number.isSafeInteger(value)) {
|
|
320
|
+
throw new Error(`scheduled job ${label} must be an integer timestamp`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const [label, value] of [
|
|
324
|
+
["attempt count", job.attemptCount],
|
|
325
|
+
["success count", job.successCount],
|
|
326
|
+
["failure count", job.failureCount],
|
|
327
|
+
] as const) {
|
|
328
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`scheduled job ${label} must be a non-negative integer`);
|
|
329
|
+
}
|
|
330
|
+
if (job.lastError !== undefined) requiredText(job.lastError, "scheduled job last error");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseLegacyState(path: string, label: string): Record<string, unknown> {
|
|
334
|
+
let raw: string;
|
|
335
|
+
try {
|
|
336
|
+
raw = readFileSync(path, "utf8");
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new Error(`could not read legacy ${label}: ${String(error)}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let parsed: unknown;
|
|
342
|
+
try {
|
|
343
|
+
parsed = JSON.parse(raw);
|
|
344
|
+
} catch {
|
|
345
|
+
throw new Error(`legacy ${label} contains invalid JSON`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!isRecord(parsed)) throw new Error(`legacy ${label} must contain a JSON object`);
|
|
349
|
+
return parsed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function legacyTelegramOperator(access: Record<string, unknown>): string {
|
|
353
|
+
const allowed = access.allowFrom;
|
|
354
|
+
if (!Array.isArray(allowed)) throw new Error("legacy access state does not contain an allowFrom array");
|
|
355
|
+
|
|
356
|
+
const numericOperators = [...new Set(allowed.filter((value): value is string => typeof value === "string" && /^\d+$/.test(value)))];
|
|
357
|
+
if (numericOperators.length !== 1) {
|
|
358
|
+
throw new Error("legacy access state must contain exactly one numeric Telegram operator");
|
|
359
|
+
}
|
|
360
|
+
return numericOperators[0]!;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function legacySessionPath(rpcState: Record<string, unknown>): string {
|
|
364
|
+
const path = typeof rpcState.sessionPath === "string" ? rpcState.sessionPath : rpcState.sessionFile;
|
|
365
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
366
|
+
throw new Error("legacy rpc state does not contain a session path");
|
|
367
|
+
}
|
|
368
|
+
return path;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function legacyUpdateId(rpcState: Record<string, unknown>): number | undefined {
|
|
372
|
+
if (!("lastUpdateId" in rpcState)) return undefined;
|
|
373
|
+
const value = rpcState.lastUpdateId;
|
|
374
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
375
|
+
throw new Error("legacy rpc state has an invalid lastUpdateId");
|
|
376
|
+
}
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Durable, transport-neutral gateway state. Principal resolution deliberately
|
|
382
|
+
* accepts only a transport identity; callers cannot supply their own Principal.
|
|
383
|
+
*/
|
|
384
|
+
export class GatewayStore {
|
|
385
|
+
readonly #database: Database;
|
|
386
|
+
|
|
387
|
+
constructor(path: string) {
|
|
388
|
+
const resolvedPath = databasePath(path);
|
|
389
|
+
const databaseExisted = existsSync(resolvedPath);
|
|
390
|
+
createPrivateDatabasePath(resolvedPath);
|
|
391
|
+
this.#database = new Database(resolvedPath, { create: true });
|
|
392
|
+
if (!databaseExisted) restrictNewDatabase(resolvedPath);
|
|
393
|
+
|
|
394
|
+
this.#database.exec(`
|
|
395
|
+
PRAGMA foreign_keys = ON;
|
|
396
|
+
PRAGMA synchronous = FULL;
|
|
397
|
+
|
|
398
|
+
CREATE TABLE IF NOT EXISTS principals (
|
|
399
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
400
|
+
roles_json TEXT NOT NULL
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
CREATE TABLE IF NOT EXISTS transport_identities (
|
|
404
|
+
transport TEXT NOT NULL,
|
|
405
|
+
account TEXT NOT NULL,
|
|
406
|
+
subject TEXT NOT NULL,
|
|
407
|
+
principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE RESTRICT,
|
|
408
|
+
PRIMARY KEY (transport, account, subject)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
CREATE TABLE IF NOT EXISTS conversation_bindings (
|
|
412
|
+
transport TEXT NOT NULL,
|
|
413
|
+
account TEXT NOT NULL,
|
|
414
|
+
channel TEXT NOT NULL,
|
|
415
|
+
thread TEXT NOT NULL,
|
|
416
|
+
omp_session_path TEXT NOT NULL,
|
|
417
|
+
workspace_json TEXT NOT NULL,
|
|
418
|
+
updated_at INTEGER NOT NULL,
|
|
419
|
+
PRIMARY KEY (transport, account, channel, thread)
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
CREATE TABLE IF NOT EXISTS adapter_checkpoints (
|
|
423
|
+
adapter TEXT NOT NULL,
|
|
424
|
+
checkpoint_key TEXT NOT NULL,
|
|
425
|
+
value_json TEXT NOT NULL,
|
|
426
|
+
updated_at INTEGER NOT NULL,
|
|
427
|
+
PRIMARY KEY (adapter, checkpoint_key)
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
CREATE TABLE IF NOT EXISTS pending_ui_interactions (
|
|
431
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
432
|
+
transport TEXT NOT NULL,
|
|
433
|
+
account TEXT NOT NULL,
|
|
434
|
+
channel TEXT NOT NULL,
|
|
435
|
+
thread TEXT NOT NULL,
|
|
436
|
+
kind TEXT NOT NULL,
|
|
437
|
+
payload_json TEXT NOT NULL,
|
|
438
|
+
created_at INTEGER NOT NULL,
|
|
439
|
+
expires_at INTEGER
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
CREATE TABLE IF NOT EXISTS inbound_messages (
|
|
443
|
+
transport TEXT NOT NULL,
|
|
444
|
+
account TEXT NOT NULL,
|
|
445
|
+
message_id TEXT NOT NULL,
|
|
446
|
+
received_at INTEGER NOT NULL,
|
|
447
|
+
PRIMARY KEY (transport, account, message_id)
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
CREATE INDEX IF NOT EXISTS inbound_messages_received_at
|
|
451
|
+
ON inbound_messages (received_at);
|
|
452
|
+
|
|
453
|
+
CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
|
454
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
455
|
+
principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE RESTRICT,
|
|
456
|
+
transport TEXT NOT NULL,
|
|
457
|
+
account TEXT NOT NULL,
|
|
458
|
+
subject TEXT NOT NULL,
|
|
459
|
+
channel TEXT NOT NULL,
|
|
460
|
+
thread TEXT NOT NULL,
|
|
461
|
+
name TEXT NOT NULL,
|
|
462
|
+
prompt TEXT NOT NULL,
|
|
463
|
+
schedule_json TEXT NOT NULL,
|
|
464
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
|
465
|
+
next_run_at INTEGER,
|
|
466
|
+
retry_at INTEGER,
|
|
467
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
468
|
+
success_count INTEGER NOT NULL DEFAULT 0,
|
|
469
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
470
|
+
created_at INTEGER NOT NULL,
|
|
471
|
+
updated_at INTEGER NOT NULL,
|
|
472
|
+
last_run_at INTEGER,
|
|
473
|
+
last_success_at INTEGER,
|
|
474
|
+
last_error TEXT
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
CREATE INDEX IF NOT EXISTS scheduled_jobs_due
|
|
478
|
+
ON scheduled_jobs (enabled, retry_at, next_run_at);
|
|
479
|
+
|
|
480
|
+
CREATE TABLE IF NOT EXISTS migration_markers (
|
|
481
|
+
marker TEXT PRIMARY KEY NOT NULL,
|
|
482
|
+
completed_at INTEGER NOT NULL
|
|
483
|
+
);
|
|
484
|
+
`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
close(): void {
|
|
488
|
+
this.#database.close();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
upsertPrincipal(principal: Principal): void {
|
|
492
|
+
requiredText(principal.id, "principal id");
|
|
493
|
+
if (!principal.roles.every((role) => typeof role === "string" && role.length > 0)) {
|
|
494
|
+
throw new Error("principal roles must be non-empty strings");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this.#database
|
|
498
|
+
.query(
|
|
499
|
+
`INSERT INTO principals (id, roles_json)
|
|
500
|
+
VALUES (?, ?)
|
|
501
|
+
ON CONFLICT(id) DO UPDATE SET roles_json = excluded.roles_json`,
|
|
502
|
+
)
|
|
503
|
+
.run(principal.id, encodeJson(principal.roles, "principal roles"));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
bindIdentity(identity: TransportIdentity, principalId: string): void {
|
|
507
|
+
validateIdentity(identity);
|
|
508
|
+
requiredText(principalId, "principal id");
|
|
509
|
+
|
|
510
|
+
const existing = this.#database
|
|
511
|
+
.query(
|
|
512
|
+
`SELECT principal_id
|
|
513
|
+
FROM transport_identities
|
|
514
|
+
WHERE transport = ? AND account = ? AND subject = ?`,
|
|
515
|
+
)
|
|
516
|
+
.get(identity.transport, identity.account, identity.subject) as SqlRow | null;
|
|
517
|
+
|
|
518
|
+
if (existing !== null) {
|
|
519
|
+
const existingPrincipalId = storedString(existing, "principal_id", "transport identity");
|
|
520
|
+
if (existingPrincipalId !== principalId) {
|
|
521
|
+
throw new Error("transport identity is already bound to a different principal");
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
this.#database
|
|
528
|
+
.query(
|
|
529
|
+
`INSERT INTO transport_identities (transport, account, subject, principal_id)
|
|
530
|
+
VALUES (?, ?, ?, ?)`,
|
|
531
|
+
)
|
|
532
|
+
.run(identity.transport, identity.account, identity.subject, principalId);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
const bound = this.#database
|
|
535
|
+
.query(
|
|
536
|
+
`SELECT principal_id
|
|
537
|
+
FROM transport_identities
|
|
538
|
+
WHERE transport = ? AND account = ? AND subject = ?`,
|
|
539
|
+
)
|
|
540
|
+
.get(identity.transport, identity.account, identity.subject) as SqlRow | null;
|
|
541
|
+
if (bound !== null && storedString(bound, "principal_id", "transport identity") !== principalId) {
|
|
542
|
+
throw new Error("transport identity is already bound to a different principal");
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
resolvePrincipal(identity: TransportIdentity): Principal | undefined {
|
|
549
|
+
validateIdentity(identity);
|
|
550
|
+
const row = this.#database
|
|
551
|
+
.query(
|
|
552
|
+
`SELECT principals.id, principals.roles_json
|
|
553
|
+
FROM transport_identities
|
|
554
|
+
JOIN principals ON principals.id = transport_identities.principal_id
|
|
555
|
+
WHERE transport_identities.transport = ?
|
|
556
|
+
AND transport_identities.account = ?
|
|
557
|
+
AND transport_identities.subject = ?`,
|
|
558
|
+
)
|
|
559
|
+
.get(identity.transport, identity.account, identity.subject) as SqlRow | null;
|
|
560
|
+
|
|
561
|
+
return row === null ? undefined : decodePrincipal(row);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
bindConversation(binding: ConversationBinding): void {
|
|
565
|
+
validateAddress(binding.address);
|
|
566
|
+
requiredText(binding.ompSessionPath, "OMP session path");
|
|
567
|
+
const workspace = encodeJson(binding.workspace, "conversation workspace");
|
|
568
|
+
const thread = binding.address.thread ?? "";
|
|
569
|
+
|
|
570
|
+
this.#database
|
|
571
|
+
.query(
|
|
572
|
+
`INSERT INTO conversation_bindings
|
|
573
|
+
(transport, account, channel, thread, omp_session_path, workspace_json, updated_at)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
575
|
+
ON CONFLICT(transport, account, channel, thread) DO UPDATE SET
|
|
576
|
+
omp_session_path = excluded.omp_session_path,
|
|
577
|
+
workspace_json = excluded.workspace_json,
|
|
578
|
+
updated_at = excluded.updated_at`,
|
|
579
|
+
)
|
|
580
|
+
.run(
|
|
581
|
+
binding.address.transport,
|
|
582
|
+
binding.address.account,
|
|
583
|
+
binding.address.channel,
|
|
584
|
+
thread,
|
|
585
|
+
binding.ompSessionPath,
|
|
586
|
+
workspace,
|
|
587
|
+
Date.now(),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
getConversationBinding(address: ConversationAddress): ConversationBinding | undefined {
|
|
592
|
+
validateAddress(address);
|
|
593
|
+
const row = this.#database
|
|
594
|
+
.query(
|
|
595
|
+
`SELECT transport, account, channel, thread, omp_session_path, workspace_json
|
|
596
|
+
FROM conversation_bindings
|
|
597
|
+
WHERE transport = ? AND account = ? AND channel = ? AND thread = ?`,
|
|
598
|
+
)
|
|
599
|
+
.get(address.transport, address.account, address.channel, address.thread ?? "") as SqlRow | null;
|
|
600
|
+
|
|
601
|
+
return row === null ? undefined : decodeConversationBinding(row);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
setCheckpoint(adapter: string, key: string, value: JsonValue): void {
|
|
605
|
+
requiredText(adapter, "adapter");
|
|
606
|
+
requiredText(key, "checkpoint key");
|
|
607
|
+
|
|
608
|
+
this.#database
|
|
609
|
+
.query(
|
|
610
|
+
`INSERT INTO adapter_checkpoints (adapter, checkpoint_key, value_json, updated_at)
|
|
611
|
+
VALUES (?, ?, ?, ?)
|
|
612
|
+
ON CONFLICT(adapter, checkpoint_key) DO UPDATE SET
|
|
613
|
+
value_json = excluded.value_json,
|
|
614
|
+
updated_at = excluded.updated_at`,
|
|
615
|
+
)
|
|
616
|
+
.run(adapter, key, encodeJson(value, "checkpoint value"), Date.now());
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
getCheckpoint(adapter: string, key: string): JsonValue | undefined {
|
|
620
|
+
requiredText(adapter, "adapter");
|
|
621
|
+
requiredText(key, "checkpoint key");
|
|
622
|
+
const row = this.#database
|
|
623
|
+
.query(
|
|
624
|
+
`SELECT value_json
|
|
625
|
+
FROM adapter_checkpoints
|
|
626
|
+
WHERE adapter = ? AND checkpoint_key = ?`,
|
|
627
|
+
)
|
|
628
|
+
.get(adapter, key) as SqlRow | null;
|
|
629
|
+
|
|
630
|
+
return row === null ? undefined : decodeJson(row.value_json, "adapter checkpoint");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
claimInboundMessage(transport: string, account: string, messageId: string, receivedAt: number): boolean {
|
|
634
|
+
requiredText(transport, "inbound message transport");
|
|
635
|
+
requiredText(account, "inbound message account");
|
|
636
|
+
requiredText(messageId, "inbound message id");
|
|
637
|
+
if (!Number.isSafeInteger(receivedAt)) {
|
|
638
|
+
throw new Error("inbound message receivedAt must be an integer timestamp");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const result = this.#database
|
|
642
|
+
.query(
|
|
643
|
+
`INSERT INTO inbound_messages (transport, account, message_id, received_at)
|
|
644
|
+
VALUES (?, ?, ?, ?)
|
|
645
|
+
ON CONFLICT(transport, account, message_id) DO NOTHING`,
|
|
646
|
+
)
|
|
647
|
+
.run(transport, account, messageId, receivedAt);
|
|
648
|
+
return result.changes > 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
releaseInboundMessage(transport: string, account: string, messageId: string): boolean {
|
|
652
|
+
requiredText(transport, "inbound message transport");
|
|
653
|
+
requiredText(account, "inbound message account");
|
|
654
|
+
requiredText(messageId, "inbound message id");
|
|
655
|
+
|
|
656
|
+
return this.#database
|
|
657
|
+
.query(
|
|
658
|
+
`DELETE FROM inbound_messages
|
|
659
|
+
WHERE transport = ? AND account = ? AND message_id = ?`,
|
|
660
|
+
)
|
|
661
|
+
.run(transport, account, messageId).changes > 0;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
pruneInboundMessages(before: number): number {
|
|
665
|
+
if (!Number.isSafeInteger(before)) {
|
|
666
|
+
throw new Error("inbound message prune before must be an integer timestamp");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return this.#database.query("DELETE FROM inbound_messages WHERE received_at < ?").run(before).changes;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
putPendingInteraction(interaction: PendingInteraction): void {
|
|
673
|
+
requiredText(interaction.id, "pending interaction id");
|
|
674
|
+
validateAddress(interaction.address);
|
|
675
|
+
requiredText(interaction.kind, "pending interaction kind");
|
|
676
|
+
if (!Number.isSafeInteger(interaction.createdAt)) {
|
|
677
|
+
throw new Error("pending interaction createdAt must be an integer timestamp");
|
|
678
|
+
}
|
|
679
|
+
if (interaction.expiresAt !== undefined && !Number.isSafeInteger(interaction.expiresAt)) {
|
|
680
|
+
throw new Error("pending interaction expiresAt must be an integer timestamp");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
this.#database
|
|
684
|
+
.query(
|
|
685
|
+
`INSERT INTO pending_ui_interactions
|
|
686
|
+
(id, transport, account, channel, thread, kind, payload_json, created_at, expires_at)
|
|
687
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
688
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
689
|
+
transport = excluded.transport,
|
|
690
|
+
account = excluded.account,
|
|
691
|
+
channel = excluded.channel,
|
|
692
|
+
thread = excluded.thread,
|
|
693
|
+
kind = excluded.kind,
|
|
694
|
+
payload_json = excluded.payload_json,
|
|
695
|
+
created_at = excluded.created_at,
|
|
696
|
+
expires_at = excluded.expires_at`,
|
|
697
|
+
)
|
|
698
|
+
.run(
|
|
699
|
+
interaction.id,
|
|
700
|
+
interaction.address.transport,
|
|
701
|
+
interaction.address.account,
|
|
702
|
+
interaction.address.channel,
|
|
703
|
+
interaction.address.thread ?? "",
|
|
704
|
+
interaction.kind,
|
|
705
|
+
encodeJson(interaction.payload, "pending interaction payload"),
|
|
706
|
+
interaction.createdAt,
|
|
707
|
+
interaction.expiresAt ?? null,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
getPendingInteraction(id: string): PendingInteraction | undefined {
|
|
712
|
+
requiredText(id, "pending interaction id");
|
|
713
|
+
const row = this.#database
|
|
714
|
+
.query(
|
|
715
|
+
`SELECT id, transport, account, channel, thread, kind, payload_json, created_at, expires_at
|
|
716
|
+
FROM pending_ui_interactions
|
|
717
|
+
WHERE id = ?`,
|
|
718
|
+
)
|
|
719
|
+
.get(id) as SqlRow | null;
|
|
720
|
+
|
|
721
|
+
return row === null ? undefined : decodePendingInteraction(row);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
listPendingInteractions(address?: ConversationAddress): PendingInteraction[] {
|
|
725
|
+
const fields = "id, transport, account, channel, thread, kind, payload_json, created_at, expires_at";
|
|
726
|
+
const order = "ORDER BY created_at ASC, id ASC";
|
|
727
|
+
|
|
728
|
+
if (address === undefined) {
|
|
729
|
+
const rows = this.#database.query(`SELECT ${fields} FROM pending_ui_interactions ${order}`).all() as SqlRow[];
|
|
730
|
+
return rows.map(decodePendingInteraction);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
validateAddress(address);
|
|
734
|
+
const rows = this.#database
|
|
735
|
+
.query(
|
|
736
|
+
`SELECT ${fields}
|
|
737
|
+
FROM pending_ui_interactions
|
|
738
|
+
WHERE transport = ? AND account = ? AND channel = ? AND thread = ?
|
|
739
|
+
${order}`,
|
|
740
|
+
)
|
|
741
|
+
.all(address.transport, address.account, address.channel, address.thread ?? "") as SqlRow[];
|
|
742
|
+
return rows.map(decodePendingInteraction);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
deletePendingInteraction(id: string): boolean {
|
|
746
|
+
requiredText(id, "pending interaction id");
|
|
747
|
+
const result = this.#database.query("DELETE FROM pending_ui_interactions WHERE id = ?").run(id);
|
|
748
|
+
return result.changes > 0;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
deleteExpiredPendingInteractions(now: number): number {
|
|
752
|
+
if (!Number.isSafeInteger(now)) {
|
|
753
|
+
throw new Error("pending interaction expiry now must be an integer timestamp");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return this.#database
|
|
757
|
+
.query("DELETE FROM pending_ui_interactions WHERE expires_at IS NOT NULL AND expires_at <= ?")
|
|
758
|
+
.run(now).changes;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
createScheduledJob(job: ScheduledJob): void {
|
|
762
|
+
validateScheduledJob(job);
|
|
763
|
+
this.#database
|
|
764
|
+
.query(
|
|
765
|
+
`INSERT INTO scheduled_jobs (
|
|
766
|
+
id, principal_id, transport, account, subject, channel, thread,
|
|
767
|
+
name, prompt, schedule_json, enabled, next_run_at, retry_at,
|
|
768
|
+
attempt_count, success_count, failure_count, created_at, updated_at,
|
|
769
|
+
last_run_at, last_success_at, last_error
|
|
770
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
771
|
+
)
|
|
772
|
+
.run(
|
|
773
|
+
job.id,
|
|
774
|
+
job.principalId,
|
|
775
|
+
job.identity.transport,
|
|
776
|
+
job.identity.account,
|
|
777
|
+
job.identity.subject,
|
|
778
|
+
job.address.channel,
|
|
779
|
+
job.address.thread ?? "",
|
|
780
|
+
job.name,
|
|
781
|
+
job.prompt,
|
|
782
|
+
encodeJson(job.schedule, "scheduled job schedule"),
|
|
783
|
+
job.enabled ? 1 : 0,
|
|
784
|
+
job.nextRunAt ?? null,
|
|
785
|
+
job.retryAt ?? null,
|
|
786
|
+
job.attemptCount,
|
|
787
|
+
job.successCount,
|
|
788
|
+
job.failureCount,
|
|
789
|
+
job.createdAt,
|
|
790
|
+
job.updatedAt,
|
|
791
|
+
job.lastRunAt ?? null,
|
|
792
|
+
job.lastSuccessAt ?? null,
|
|
793
|
+
job.lastError ?? null,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
updateScheduledJob(job: ScheduledJob): boolean {
|
|
798
|
+
validateScheduledJob(job);
|
|
799
|
+
const result = this.#database
|
|
800
|
+
.query(
|
|
801
|
+
`UPDATE scheduled_jobs SET
|
|
802
|
+
transport = ?, account = ?, subject = ?, channel = ?, thread = ?,
|
|
803
|
+
name = ?, prompt = ?, schedule_json = ?, enabled = ?,
|
|
804
|
+
next_run_at = ?, retry_at = ?, attempt_count = ?,
|
|
805
|
+
success_count = ?, failure_count = ?, updated_at = ?,
|
|
806
|
+
last_run_at = ?, last_success_at = ?, last_error = ?
|
|
807
|
+
WHERE id = ? AND principal_id = ?`,
|
|
808
|
+
)
|
|
809
|
+
.run(
|
|
810
|
+
job.identity.transport,
|
|
811
|
+
job.identity.account,
|
|
812
|
+
job.identity.subject,
|
|
813
|
+
job.address.channel,
|
|
814
|
+
job.address.thread ?? "",
|
|
815
|
+
job.name,
|
|
816
|
+
job.prompt,
|
|
817
|
+
encodeJson(job.schedule, "scheduled job schedule"),
|
|
818
|
+
job.enabled ? 1 : 0,
|
|
819
|
+
job.nextRunAt ?? null,
|
|
820
|
+
job.retryAt ?? null,
|
|
821
|
+
job.attemptCount,
|
|
822
|
+
job.successCount,
|
|
823
|
+
job.failureCount,
|
|
824
|
+
job.updatedAt,
|
|
825
|
+
job.lastRunAt ?? null,
|
|
826
|
+
job.lastSuccessAt ?? null,
|
|
827
|
+
job.lastError ?? null,
|
|
828
|
+
job.id,
|
|
829
|
+
job.principalId,
|
|
830
|
+
);
|
|
831
|
+
return result.changes > 0;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
getScheduledJob(id: string, principalId?: string): ScheduledJob | undefined {
|
|
835
|
+
requiredText(id, "scheduled job id");
|
|
836
|
+
if (principalId !== undefined) requiredText(principalId, "scheduled job principal");
|
|
837
|
+
const row = principalId === undefined
|
|
838
|
+
? this.#database.query(`SELECT ${SCHEDULED_JOB_FIELDS} FROM scheduled_jobs WHERE id = ?`).get(id)
|
|
839
|
+
: this.#database
|
|
840
|
+
.query(`SELECT ${SCHEDULED_JOB_FIELDS} FROM scheduled_jobs WHERE id = ? AND principal_id = ?`)
|
|
841
|
+
.get(id, principalId);
|
|
842
|
+
return row === null ? undefined : decodeScheduledJob(row as SqlRow);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
listScheduledJobs(principalId?: string): ScheduledJob[] {
|
|
846
|
+
if (principalId !== undefined) requiredText(principalId, "scheduled job principal");
|
|
847
|
+
const rows = principalId === undefined
|
|
848
|
+
? this.#database.query(`SELECT ${SCHEDULED_JOB_FIELDS} FROM scheduled_jobs ORDER BY created_at ASC, id ASC`).all()
|
|
849
|
+
: this.#database
|
|
850
|
+
.query(`SELECT ${SCHEDULED_JOB_FIELDS} FROM scheduled_jobs WHERE principal_id = ? ORDER BY created_at ASC, id ASC`)
|
|
851
|
+
.all(principalId);
|
|
852
|
+
return (rows as SqlRow[]).map(decodeScheduledJob);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
listDueScheduledJobs(now: number, limit = 16): ScheduledJob[] {
|
|
856
|
+
if (!Number.isSafeInteger(now)) throw new Error("scheduled job due time must be an integer timestamp");
|
|
857
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 128) {
|
|
858
|
+
throw new Error("scheduled job due limit must be an integer between 1 and 128");
|
|
859
|
+
}
|
|
860
|
+
const rows = this.#database
|
|
861
|
+
.query(
|
|
862
|
+
`SELECT ${SCHEDULED_JOB_FIELDS}
|
|
863
|
+
FROM scheduled_jobs
|
|
864
|
+
WHERE enabled = 1
|
|
865
|
+
AND next_run_at IS NOT NULL
|
|
866
|
+
AND COALESCE(retry_at, next_run_at) <= ?
|
|
867
|
+
ORDER BY COALESCE(retry_at, next_run_at) ASC, created_at ASC
|
|
868
|
+
LIMIT ?`,
|
|
869
|
+
)
|
|
870
|
+
.all(now, limit) as SqlRow[];
|
|
871
|
+
return rows.map(decodeScheduledJob);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
deleteScheduledJob(id: string, principalId: string): boolean {
|
|
875
|
+
requiredText(id, "scheduled job id");
|
|
876
|
+
requiredText(principalId, "scheduled job principal");
|
|
877
|
+
return this.#database
|
|
878
|
+
.query("DELETE FROM scheduled_jobs WHERE id = ? AND principal_id = ?")
|
|
879
|
+
.run(id, principalId).changes > 0;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
importLegacyTelegramState(options: LegacyTelegramStateImportOptions): LegacyTelegramStateImportResult {
|
|
883
|
+
if (this.#hasMigration(LEGACY_TELEGRAM_STATE_MIGRATION)) {
|
|
884
|
+
return { imported: false, checkpointImported: false };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
requiredText(options.accessPath, "legacy access path");
|
|
888
|
+
requiredText(options.rpcStatePath, "legacy rpc state path");
|
|
889
|
+
if (!isJsonValue(options.workspace)) throw new Error("legacy workspace must be a JSON value");
|
|
890
|
+
|
|
891
|
+
// These are the only two legacy files read. In particular, the old .env
|
|
892
|
+
// token file is never opened or copied.
|
|
893
|
+
const access = parseLegacyState(options.accessPath, "access state");
|
|
894
|
+
const rpcState = parseLegacyState(options.rpcStatePath, "rpc state");
|
|
895
|
+
const operator = legacyTelegramOperator(access);
|
|
896
|
+
const ompSessionPath = legacySessionPath(rpcState);
|
|
897
|
+
const lastUpdateId = legacyUpdateId(rpcState);
|
|
898
|
+
const principal: Principal = { id: `telegram:default:${operator}`, roles: ["operator"] };
|
|
899
|
+
const identity: TransportIdentity = { transport: "telegram", account: "default", subject: operator };
|
|
900
|
+
const binding: ConversationBinding = {
|
|
901
|
+
address: { transport: "telegram", account: "default", channel: operator },
|
|
902
|
+
ompSessionPath,
|
|
903
|
+
workspace: options.workspace,
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
return this.#transaction(() => {
|
|
907
|
+
if (this.#hasMigration(LEGACY_TELEGRAM_STATE_MIGRATION)) {
|
|
908
|
+
return { imported: false, checkpointImported: false };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
this.upsertPrincipal(principal);
|
|
912
|
+
this.bindIdentity(identity, principal.id);
|
|
913
|
+
this.bindConversation(binding);
|
|
914
|
+
if (lastUpdateId !== undefined) this.setCheckpoint("telegram", "update_id", String(lastUpdateId));
|
|
915
|
+
this.#database
|
|
916
|
+
.query("INSERT INTO migration_markers (marker, completed_at) VALUES (?, ?)")
|
|
917
|
+
.run(LEGACY_TELEGRAM_STATE_MIGRATION, Date.now());
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
imported: true,
|
|
921
|
+
principal,
|
|
922
|
+
binding,
|
|
923
|
+
checkpointImported: lastUpdateId !== undefined,
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
#hasMigration(marker: string): boolean {
|
|
929
|
+
return this.#database.query("SELECT 1 FROM migration_markers WHERE marker = ?").get(marker) !== null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
#transaction<Result>(work: () => Result): Result {
|
|
933
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
934
|
+
try {
|
|
935
|
+
const result = work();
|
|
936
|
+
this.#database.exec("COMMIT");
|
|
937
|
+
return result;
|
|
938
|
+
} catch (error) {
|
|
939
|
+
try {
|
|
940
|
+
this.#database.exec("ROLLBACK");
|
|
941
|
+
} catch {
|
|
942
|
+
// If BEGIN failed or SQLite already rolled back, retain the original error.
|
|
943
|
+
}
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|