@frontmcp/storage-sqlite 1.0.3 → 1.1.0-beta.1
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/esm/index.mjs +274 -1
- package/esm/package.json +3 -3
- package/index.d.ts +2 -0
- package/index.d.ts.map +1 -1
- package/index.js +281 -7
- package/package.json +3 -3
- package/sqlite-task.store.d.ts +140 -0
- package/sqlite-task.store.d.ts.map +1 -0
- package/sqlite.options.d.ts +9 -9
- package/sqlite.options.d.ts.map +1 -1
package/esm/index.mjs
CHANGED
|
@@ -371,6 +371,278 @@ var SqliteElicitationStore = class {
|
|
|
371
371
|
}
|
|
372
372
|
};
|
|
373
373
|
|
|
374
|
+
// libs/storage-sqlite/src/sqlite-task.store.ts
|
|
375
|
+
import { EventEmitter as EventEmitter2 } from "node:events";
|
|
376
|
+
var defaultLivenessProbe = (pid) => {
|
|
377
|
+
try {
|
|
378
|
+
process.kill(pid, 0);
|
|
379
|
+
return true;
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
var DEFAULT_PAGE_SIZE = 50;
|
|
385
|
+
var SqliteTaskStore = class {
|
|
386
|
+
db;
|
|
387
|
+
emitter = new EventEmitter2();
|
|
388
|
+
logger;
|
|
389
|
+
liveness;
|
|
390
|
+
cleanupTimer;
|
|
391
|
+
/**
|
|
392
|
+
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
|
|
393
|
+
* `record_json` column on write and reversed on read. Other columns
|
|
394
|
+
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
|
|
395
|
+
* in plaintext so the indexes remain usable.
|
|
396
|
+
*/
|
|
397
|
+
encryptionKey = null;
|
|
398
|
+
stmts = null;
|
|
399
|
+
constructor(options) {
|
|
400
|
+
const BetterSqlite3 = __require("better-sqlite3");
|
|
401
|
+
try {
|
|
402
|
+
this.db = new BetterSqlite3(options.path);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
405
|
+
throw new Error(`SqliteTaskStore: failed to open database at "${options.path}": ${message}`);
|
|
406
|
+
}
|
|
407
|
+
if (options.walMode !== false) {
|
|
408
|
+
this.db.pragma("journal_mode = WAL");
|
|
409
|
+
}
|
|
410
|
+
this.logger = options.logger;
|
|
411
|
+
this.liveness = options.livenessProbe ?? defaultLivenessProbe;
|
|
412
|
+
if (options.encryption?.secret) {
|
|
413
|
+
this.encryptionKey = deriveEncryptionKey(options.encryption.secret);
|
|
414
|
+
}
|
|
415
|
+
this.emitter.setMaxListeners(200);
|
|
416
|
+
this.initSchema();
|
|
417
|
+
this.prepareStatements();
|
|
418
|
+
const cleanupInterval = options.ttlCleanupIntervalMs ?? 6e4;
|
|
419
|
+
if (cleanupInterval > 0) {
|
|
420
|
+
this.cleanupTimer = setInterval(() => this.purgeExpired(), cleanupInterval);
|
|
421
|
+
this.cleanupTimer.unref?.();
|
|
422
|
+
} else {
|
|
423
|
+
this.cleanupTimer = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
initSchema() {
|
|
427
|
+
this.db.exec(`
|
|
428
|
+
CREATE TABLE IF NOT EXISTS mcp_tasks (
|
|
429
|
+
task_id TEXT PRIMARY KEY,
|
|
430
|
+
session_id TEXT NOT NULL,
|
|
431
|
+
status TEXT NOT NULL,
|
|
432
|
+
expires_at INTEGER NOT NULL,
|
|
433
|
+
created_at INTEGER NOT NULL,
|
|
434
|
+
updated_at INTEGER NOT NULL,
|
|
435
|
+
executor_pid INTEGER,
|
|
436
|
+
record_json TEXT NOT NULL
|
|
437
|
+
);
|
|
438
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_session ON mcp_tasks (session_id);
|
|
439
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_status ON mcp_tasks (status);
|
|
440
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_expires ON mcp_tasks (expires_at);
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
prepareStatements() {
|
|
444
|
+
this.stmts = {
|
|
445
|
+
insert: this.db.prepare(
|
|
446
|
+
"INSERT INTO mcp_tasks (task_id, session_id, status, expires_at, created_at, updated_at, executor_pid, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
447
|
+
),
|
|
448
|
+
get: this.db.prepare("SELECT * FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
|
|
449
|
+
update: this.db.prepare(
|
|
450
|
+
"UPDATE mcp_tasks SET status = ?, expires_at = ?, updated_at = ?, executor_pid = ?, record_json = ? WHERE task_id = ? AND session_id = ?"
|
|
451
|
+
),
|
|
452
|
+
del: this.db.prepare("DELETE FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
|
|
453
|
+
listBySession: this.db.prepare(
|
|
454
|
+
"SELECT * FROM mcp_tasks WHERE session_id = ? AND expires_at > ? ORDER BY created_at ASC, task_id ASC LIMIT ? OFFSET ?"
|
|
455
|
+
),
|
|
456
|
+
countBySession: this.db.prepare("SELECT COUNT(*) as n FROM mcp_tasks WHERE session_id = ? AND expires_at > ?"),
|
|
457
|
+
cleanup: this.db.prepare("DELETE FROM mcp_tasks WHERE expires_at <= ?")
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
prepared() {
|
|
461
|
+
if (!this.stmts) throw new Error("SqliteTaskStore: prepared statements not initialized");
|
|
462
|
+
return this.stmts;
|
|
463
|
+
}
|
|
464
|
+
// ───────────────────── CRUD ─────────────────────
|
|
465
|
+
async create(record) {
|
|
466
|
+
if (record.expiresAt <= Date.now()) {
|
|
467
|
+
this.logger?.warn?.("[SqliteTaskStore] create: record already expired", { taskId: record.taskId });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.prepared().insert.run(
|
|
471
|
+
record.taskId,
|
|
472
|
+
record.sessionId,
|
|
473
|
+
record.status,
|
|
474
|
+
record.expiresAt,
|
|
475
|
+
new Date(record.createdAt).getTime(),
|
|
476
|
+
new Date(record.lastUpdatedAt).getTime(),
|
|
477
|
+
record.executor?.pid ?? null,
|
|
478
|
+
this.serializeRecord(record)
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
async get(taskId, sessionId) {
|
|
482
|
+
const row = this.prepared().get.get(taskId, sessionId);
|
|
483
|
+
if (!row) return null;
|
|
484
|
+
if (row.expires_at <= Date.now()) {
|
|
485
|
+
this.prepared().del.run(taskId, sessionId);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const record = this.rowToRecord(row);
|
|
489
|
+
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
|
|
490
|
+
const patched = await this.update(taskId, sessionId, {
|
|
491
|
+
status: "failed",
|
|
492
|
+
statusMessage: "Task runner exited before completing the task"
|
|
493
|
+
});
|
|
494
|
+
if (patched) {
|
|
495
|
+
await this.publishTerminal(patched);
|
|
496
|
+
return patched;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return record;
|
|
500
|
+
}
|
|
501
|
+
async update(taskId, sessionId, patch) {
|
|
502
|
+
const row = this.prepared().get.get(taskId, sessionId);
|
|
503
|
+
if (!row) return null;
|
|
504
|
+
if (row.expires_at <= Date.now()) {
|
|
505
|
+
this.prepared().del.run(taskId, sessionId);
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
const existing = this.rowToRecord(row);
|
|
509
|
+
const merged = {
|
|
510
|
+
...existing,
|
|
511
|
+
...patch,
|
|
512
|
+
taskId: existing.taskId,
|
|
513
|
+
sessionId: existing.sessionId,
|
|
514
|
+
createdAt: existing.createdAt,
|
|
515
|
+
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
516
|
+
};
|
|
517
|
+
if (merged.expiresAt <= Date.now()) {
|
|
518
|
+
this.prepared().del.run(taskId, sessionId);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
this.prepared().update.run(
|
|
522
|
+
merged.status,
|
|
523
|
+
merged.expiresAt,
|
|
524
|
+
new Date(merged.lastUpdatedAt).getTime(),
|
|
525
|
+
merged.executor?.pid ?? null,
|
|
526
|
+
this.serializeRecord(merged),
|
|
527
|
+
taskId,
|
|
528
|
+
sessionId
|
|
529
|
+
);
|
|
530
|
+
return merged;
|
|
531
|
+
}
|
|
532
|
+
async delete(taskId, sessionId) {
|
|
533
|
+
this.prepared().del.run(taskId, sessionId);
|
|
534
|
+
}
|
|
535
|
+
async list(sessionId, opts = {}) {
|
|
536
|
+
const pageSize = Math.max(1, Math.min(opts.pageSize ?? DEFAULT_PAGE_SIZE, 500));
|
|
537
|
+
const offset = opts.cursor ? decodeCursor(opts.cursor) : 0;
|
|
538
|
+
const rows = this.prepared().listBySession.all(sessionId, Date.now(), pageSize, offset);
|
|
539
|
+
const tasks = [];
|
|
540
|
+
for (const row of rows) {
|
|
541
|
+
let record = this.rowToRecord(row);
|
|
542
|
+
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
|
|
543
|
+
const patched = await this.update(record.taskId, sessionId, {
|
|
544
|
+
status: "failed",
|
|
545
|
+
statusMessage: "Task runner exited before completing the task"
|
|
546
|
+
});
|
|
547
|
+
if (patched) {
|
|
548
|
+
await this.publishTerminal(patched);
|
|
549
|
+
record = patched;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
tasks.push(record);
|
|
553
|
+
}
|
|
554
|
+
const total = this.prepared().countBySession.get(sessionId, Date.now()).n;
|
|
555
|
+
const page = { tasks };
|
|
556
|
+
if (offset + rows.length < total) {
|
|
557
|
+
page.nextCursor = encodeCursor(offset + rows.length);
|
|
558
|
+
}
|
|
559
|
+
return page;
|
|
560
|
+
}
|
|
561
|
+
// ───────────────────── Pub/Sub (in-process) ─────────────────────
|
|
562
|
+
async subscribeTerminal(taskId, _sessionId, cb) {
|
|
563
|
+
const channel = `terminal:${taskId}`;
|
|
564
|
+
const handler = (record) => cb(record);
|
|
565
|
+
this.emitter.on(channel, handler);
|
|
566
|
+
return async () => {
|
|
567
|
+
this.emitter.removeListener(channel, handler);
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
async publishTerminal(record) {
|
|
571
|
+
this.emitter.emit(`terminal:${record.taskId}`, record);
|
|
572
|
+
}
|
|
573
|
+
async subscribeCancel(taskId, _sessionId, cb) {
|
|
574
|
+
const channel = `cancel:${taskId}`;
|
|
575
|
+
const handler = () => cb();
|
|
576
|
+
this.emitter.on(channel, handler);
|
|
577
|
+
return async () => {
|
|
578
|
+
this.emitter.removeListener(channel, handler);
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
async publishCancel(taskId, _sessionId) {
|
|
582
|
+
this.emitter.emit(`cancel:${taskId}`);
|
|
583
|
+
}
|
|
584
|
+
// ───────────────────── Misc ─────────────────────
|
|
585
|
+
async destroy() {
|
|
586
|
+
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
|
587
|
+
this.emitter.removeAllListeners();
|
|
588
|
+
try {
|
|
589
|
+
this.db.close();
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
purgeExpired() {
|
|
594
|
+
try {
|
|
595
|
+
const res = this.prepared().cleanup.run(Date.now());
|
|
596
|
+
return res.changes;
|
|
597
|
+
} catch (err) {
|
|
598
|
+
this.logger?.warn?.("[SqliteTaskStore] purgeExpired failed", {
|
|
599
|
+
error: err instanceof Error ? err.message : String(err)
|
|
600
|
+
});
|
|
601
|
+
return 0;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
getDatabase() {
|
|
605
|
+
return this.db;
|
|
606
|
+
}
|
|
607
|
+
rowToRecord(row) {
|
|
608
|
+
const plaintext = this.encryptionKey ? decryptValue(this.encryptionKey, row.record_json) : row.record_json;
|
|
609
|
+
return JSON.parse(plaintext);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
|
|
613
|
+
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
|
|
614
|
+
*/
|
|
615
|
+
serializeRecord(record) {
|
|
616
|
+
const json = JSON.stringify(record);
|
|
617
|
+
return this.encryptionKey ? encryptValue(this.encryptionKey, json) : json;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
function encodeCursor(offset) {
|
|
621
|
+
return toB64Url(new TextEncoder().encode(JSON.stringify({ offset })));
|
|
622
|
+
}
|
|
623
|
+
function decodeCursor(cursor) {
|
|
624
|
+
try {
|
|
625
|
+
const bytes = fromB64Url(cursor);
|
|
626
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
627
|
+
if (typeof parsed?.offset === "number" && parsed.offset >= 0 && Number.isInteger(parsed.offset)) {
|
|
628
|
+
return parsed.offset;
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
function toB64Url(data) {
|
|
635
|
+
const b64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(Array.from(data, (b) => String.fromCodePoint(b)).join(""));
|
|
636
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
637
|
+
}
|
|
638
|
+
function fromB64Url(data) {
|
|
639
|
+
let b64 = data.replace(/-/g, "+").replace(/_/g, "/");
|
|
640
|
+
while (b64.length % 4) b64 += "=";
|
|
641
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
|
|
642
|
+
const bin = atob(b64);
|
|
643
|
+
return Uint8Array.from(bin, (c) => c.codePointAt(0) ?? 0);
|
|
644
|
+
}
|
|
645
|
+
|
|
374
646
|
// libs/storage-sqlite/src/sqlite-event.store.ts
|
|
375
647
|
var SqliteEventStore = class {
|
|
376
648
|
db;
|
|
@@ -518,7 +790,7 @@ var SqliteEventStore = class {
|
|
|
518
790
|
};
|
|
519
791
|
|
|
520
792
|
// libs/storage-sqlite/src/sqlite.options.ts
|
|
521
|
-
import { z } from "zod";
|
|
793
|
+
import { z } from "@frontmcp/lazy-zod";
|
|
522
794
|
var sqliteStorageOptionsSchema = z.object({
|
|
523
795
|
path: z.string().min(1),
|
|
524
796
|
encryption: z.object({
|
|
@@ -532,6 +804,7 @@ export {
|
|
|
532
804
|
SqliteEventStore,
|
|
533
805
|
SqliteKvStore,
|
|
534
806
|
SqliteSessionStore,
|
|
807
|
+
SqliteTaskStore,
|
|
535
808
|
decryptValue,
|
|
536
809
|
deriveEncryptionKey,
|
|
537
810
|
encryptValue,
|
package/esm/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/storage-sqlite",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.1.0-beta.1",
|
|
4
4
|
"description": "SQLite storage backend for FrontMCP - local session, elicitation, and event persistence without Redis",
|
|
5
5
|
"author": "AgentFront <info@agentfront.dev>",
|
|
6
6
|
"homepage": "https://docs.agentfront.dev",
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"node": ">=24.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@frontmcp/utils": "1.0.
|
|
47
|
+
"@frontmcp/utils": "1.1.0-beta.1",
|
|
48
48
|
"better-sqlite3": "^12.6.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"zod": "
|
|
51
|
+
"@frontmcp/lazy-zod": "1.1.0-beta.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/better-sqlite3": "^7.6.13",
|
package/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { SqliteSessionStore } from './sqlite-session.store';
|
|
|
9
9
|
export type { SqliteSessionStoreOptions, SessionStoreInterface, StoredSessionData } from './sqlite-session.store';
|
|
10
10
|
export { SqliteElicitationStore } from './sqlite-elicitation.store';
|
|
11
11
|
export type { SqliteElicitationStoreOptions, ElicitationStoreInterface } from './sqlite-elicitation.store';
|
|
12
|
+
export { SqliteTaskStore } from './sqlite-task.store';
|
|
13
|
+
export type { SqliteTaskStoreOptions, TaskStoreInterface, TaskRecord as SqliteTaskRecord, TaskStatus as SqliteTaskStatus, TaskOutcome as SqliteTaskOutcome, TaskListPage as SqliteTaskListPage, ProcessLivenessProbe, SqliteTaskLogger, } from './sqlite-task.store';
|
|
12
14
|
export { SqliteEventStore } from './sqlite-event.store';
|
|
13
15
|
export type { SqliteEventStoreOptions, EventStoreInterface } from './sqlite-event.store';
|
|
14
16
|
export { deriveEncryptionKey, encryptValue, decryptValue } from './encryption';
|
package/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,YAAY,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAClH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACpE,YAAY,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC3G,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAC9D,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,YAAY,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAClH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACpE,YAAY,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC3G,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,YAAY,EACV,sBAAsB,EACtB,kBAAkB,EAClB,UAAU,IAAI,gBAAgB,EAC9B,UAAU,IAAI,gBAAgB,EAC9B,WAAW,IAAI,iBAAiB,EAChC,YAAY,IAAI,kBAAkB,EAClC,oBAAoB,EACpB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAC9D,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC"}
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ __export(index_exports, {
|
|
|
24
24
|
SqliteEventStore: () => SqliteEventStore,
|
|
25
25
|
SqliteKvStore: () => SqliteKvStore,
|
|
26
26
|
SqliteSessionStore: () => SqliteSessionStore,
|
|
27
|
+
SqliteTaskStore: () => SqliteTaskStore,
|
|
27
28
|
decryptValue: () => decryptValue,
|
|
28
29
|
deriveEncryptionKey: () => deriveEncryptionKey,
|
|
29
30
|
encryptValue: () => encryptValue,
|
|
@@ -390,6 +391,278 @@ var SqliteElicitationStore = class {
|
|
|
390
391
|
}
|
|
391
392
|
};
|
|
392
393
|
|
|
394
|
+
// libs/storage-sqlite/src/sqlite-task.store.ts
|
|
395
|
+
var import_node_events2 = require("node:events");
|
|
396
|
+
var defaultLivenessProbe = (pid) => {
|
|
397
|
+
try {
|
|
398
|
+
process.kill(pid, 0);
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
var DEFAULT_PAGE_SIZE = 50;
|
|
405
|
+
var SqliteTaskStore = class {
|
|
406
|
+
db;
|
|
407
|
+
emitter = new import_node_events2.EventEmitter();
|
|
408
|
+
logger;
|
|
409
|
+
liveness;
|
|
410
|
+
cleanupTimer;
|
|
411
|
+
/**
|
|
412
|
+
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
|
|
413
|
+
* `record_json` column on write and reversed on read. Other columns
|
|
414
|
+
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
|
|
415
|
+
* in plaintext so the indexes remain usable.
|
|
416
|
+
*/
|
|
417
|
+
encryptionKey = null;
|
|
418
|
+
stmts = null;
|
|
419
|
+
constructor(options) {
|
|
420
|
+
const BetterSqlite3 = require("better-sqlite3");
|
|
421
|
+
try {
|
|
422
|
+
this.db = new BetterSqlite3(options.path);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
425
|
+
throw new Error(`SqliteTaskStore: failed to open database at "${options.path}": ${message}`);
|
|
426
|
+
}
|
|
427
|
+
if (options.walMode !== false) {
|
|
428
|
+
this.db.pragma("journal_mode = WAL");
|
|
429
|
+
}
|
|
430
|
+
this.logger = options.logger;
|
|
431
|
+
this.liveness = options.livenessProbe ?? defaultLivenessProbe;
|
|
432
|
+
if (options.encryption?.secret) {
|
|
433
|
+
this.encryptionKey = deriveEncryptionKey(options.encryption.secret);
|
|
434
|
+
}
|
|
435
|
+
this.emitter.setMaxListeners(200);
|
|
436
|
+
this.initSchema();
|
|
437
|
+
this.prepareStatements();
|
|
438
|
+
const cleanupInterval = options.ttlCleanupIntervalMs ?? 6e4;
|
|
439
|
+
if (cleanupInterval > 0) {
|
|
440
|
+
this.cleanupTimer = setInterval(() => this.purgeExpired(), cleanupInterval);
|
|
441
|
+
this.cleanupTimer.unref?.();
|
|
442
|
+
} else {
|
|
443
|
+
this.cleanupTimer = null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
initSchema() {
|
|
447
|
+
this.db.exec(`
|
|
448
|
+
CREATE TABLE IF NOT EXISTS mcp_tasks (
|
|
449
|
+
task_id TEXT PRIMARY KEY,
|
|
450
|
+
session_id TEXT NOT NULL,
|
|
451
|
+
status TEXT NOT NULL,
|
|
452
|
+
expires_at INTEGER NOT NULL,
|
|
453
|
+
created_at INTEGER NOT NULL,
|
|
454
|
+
updated_at INTEGER NOT NULL,
|
|
455
|
+
executor_pid INTEGER,
|
|
456
|
+
record_json TEXT NOT NULL
|
|
457
|
+
);
|
|
458
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_session ON mcp_tasks (session_id);
|
|
459
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_status ON mcp_tasks (status);
|
|
460
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_tasks_expires ON mcp_tasks (expires_at);
|
|
461
|
+
`);
|
|
462
|
+
}
|
|
463
|
+
prepareStatements() {
|
|
464
|
+
this.stmts = {
|
|
465
|
+
insert: this.db.prepare(
|
|
466
|
+
"INSERT INTO mcp_tasks (task_id, session_id, status, expires_at, created_at, updated_at, executor_pid, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
467
|
+
),
|
|
468
|
+
get: this.db.prepare("SELECT * FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
|
|
469
|
+
update: this.db.prepare(
|
|
470
|
+
"UPDATE mcp_tasks SET status = ?, expires_at = ?, updated_at = ?, executor_pid = ?, record_json = ? WHERE task_id = ? AND session_id = ?"
|
|
471
|
+
),
|
|
472
|
+
del: this.db.prepare("DELETE FROM mcp_tasks WHERE task_id = ? AND session_id = ?"),
|
|
473
|
+
listBySession: this.db.prepare(
|
|
474
|
+
"SELECT * FROM mcp_tasks WHERE session_id = ? AND expires_at > ? ORDER BY created_at ASC, task_id ASC LIMIT ? OFFSET ?"
|
|
475
|
+
),
|
|
476
|
+
countBySession: this.db.prepare("SELECT COUNT(*) as n FROM mcp_tasks WHERE session_id = ? AND expires_at > ?"),
|
|
477
|
+
cleanup: this.db.prepare("DELETE FROM mcp_tasks WHERE expires_at <= ?")
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
prepared() {
|
|
481
|
+
if (!this.stmts) throw new Error("SqliteTaskStore: prepared statements not initialized");
|
|
482
|
+
return this.stmts;
|
|
483
|
+
}
|
|
484
|
+
// ───────────────────── CRUD ─────────────────────
|
|
485
|
+
async create(record) {
|
|
486
|
+
if (record.expiresAt <= Date.now()) {
|
|
487
|
+
this.logger?.warn?.("[SqliteTaskStore] create: record already expired", { taskId: record.taskId });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.prepared().insert.run(
|
|
491
|
+
record.taskId,
|
|
492
|
+
record.sessionId,
|
|
493
|
+
record.status,
|
|
494
|
+
record.expiresAt,
|
|
495
|
+
new Date(record.createdAt).getTime(),
|
|
496
|
+
new Date(record.lastUpdatedAt).getTime(),
|
|
497
|
+
record.executor?.pid ?? null,
|
|
498
|
+
this.serializeRecord(record)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
async get(taskId, sessionId) {
|
|
502
|
+
const row = this.prepared().get.get(taskId, sessionId);
|
|
503
|
+
if (!row) return null;
|
|
504
|
+
if (row.expires_at <= Date.now()) {
|
|
505
|
+
this.prepared().del.run(taskId, sessionId);
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
const record = this.rowToRecord(row);
|
|
509
|
+
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
|
|
510
|
+
const patched = await this.update(taskId, sessionId, {
|
|
511
|
+
status: "failed",
|
|
512
|
+
statusMessage: "Task runner exited before completing the task"
|
|
513
|
+
});
|
|
514
|
+
if (patched) {
|
|
515
|
+
await this.publishTerminal(patched);
|
|
516
|
+
return patched;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return record;
|
|
520
|
+
}
|
|
521
|
+
async update(taskId, sessionId, patch) {
|
|
522
|
+
const row = this.prepared().get.get(taskId, sessionId);
|
|
523
|
+
if (!row) return null;
|
|
524
|
+
if (row.expires_at <= Date.now()) {
|
|
525
|
+
this.prepared().del.run(taskId, sessionId);
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
const existing = this.rowToRecord(row);
|
|
529
|
+
const merged = {
|
|
530
|
+
...existing,
|
|
531
|
+
...patch,
|
|
532
|
+
taskId: existing.taskId,
|
|
533
|
+
sessionId: existing.sessionId,
|
|
534
|
+
createdAt: existing.createdAt,
|
|
535
|
+
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
536
|
+
};
|
|
537
|
+
if (merged.expiresAt <= Date.now()) {
|
|
538
|
+
this.prepared().del.run(taskId, sessionId);
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
this.prepared().update.run(
|
|
542
|
+
merged.status,
|
|
543
|
+
merged.expiresAt,
|
|
544
|
+
new Date(merged.lastUpdatedAt).getTime(),
|
|
545
|
+
merged.executor?.pid ?? null,
|
|
546
|
+
this.serializeRecord(merged),
|
|
547
|
+
taskId,
|
|
548
|
+
sessionId
|
|
549
|
+
);
|
|
550
|
+
return merged;
|
|
551
|
+
}
|
|
552
|
+
async delete(taskId, sessionId) {
|
|
553
|
+
this.prepared().del.run(taskId, sessionId);
|
|
554
|
+
}
|
|
555
|
+
async list(sessionId, opts = {}) {
|
|
556
|
+
const pageSize = Math.max(1, Math.min(opts.pageSize ?? DEFAULT_PAGE_SIZE, 500));
|
|
557
|
+
const offset = opts.cursor ? decodeCursor(opts.cursor) : 0;
|
|
558
|
+
const rows = this.prepared().listBySession.all(sessionId, Date.now(), pageSize, offset);
|
|
559
|
+
const tasks = [];
|
|
560
|
+
for (const row of rows) {
|
|
561
|
+
let record = this.rowToRecord(row);
|
|
562
|
+
if ((record.status === "working" || record.status === "input_required") && record.executor?.host === "cli" && typeof record.executor.pid === "number" && !this.liveness(record.executor.pid)) {
|
|
563
|
+
const patched = await this.update(record.taskId, sessionId, {
|
|
564
|
+
status: "failed",
|
|
565
|
+
statusMessage: "Task runner exited before completing the task"
|
|
566
|
+
});
|
|
567
|
+
if (patched) {
|
|
568
|
+
await this.publishTerminal(patched);
|
|
569
|
+
record = patched;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
tasks.push(record);
|
|
573
|
+
}
|
|
574
|
+
const total = this.prepared().countBySession.get(sessionId, Date.now()).n;
|
|
575
|
+
const page = { tasks };
|
|
576
|
+
if (offset + rows.length < total) {
|
|
577
|
+
page.nextCursor = encodeCursor(offset + rows.length);
|
|
578
|
+
}
|
|
579
|
+
return page;
|
|
580
|
+
}
|
|
581
|
+
// ───────────────────── Pub/Sub (in-process) ─────────────────────
|
|
582
|
+
async subscribeTerminal(taskId, _sessionId, cb) {
|
|
583
|
+
const channel = `terminal:${taskId}`;
|
|
584
|
+
const handler = (record) => cb(record);
|
|
585
|
+
this.emitter.on(channel, handler);
|
|
586
|
+
return async () => {
|
|
587
|
+
this.emitter.removeListener(channel, handler);
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
async publishTerminal(record) {
|
|
591
|
+
this.emitter.emit(`terminal:${record.taskId}`, record);
|
|
592
|
+
}
|
|
593
|
+
async subscribeCancel(taskId, _sessionId, cb) {
|
|
594
|
+
const channel = `cancel:${taskId}`;
|
|
595
|
+
const handler = () => cb();
|
|
596
|
+
this.emitter.on(channel, handler);
|
|
597
|
+
return async () => {
|
|
598
|
+
this.emitter.removeListener(channel, handler);
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async publishCancel(taskId, _sessionId) {
|
|
602
|
+
this.emitter.emit(`cancel:${taskId}`);
|
|
603
|
+
}
|
|
604
|
+
// ───────────────────── Misc ─────────────────────
|
|
605
|
+
async destroy() {
|
|
606
|
+
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
|
607
|
+
this.emitter.removeAllListeners();
|
|
608
|
+
try {
|
|
609
|
+
this.db.close();
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
purgeExpired() {
|
|
614
|
+
try {
|
|
615
|
+
const res = this.prepared().cleanup.run(Date.now());
|
|
616
|
+
return res.changes;
|
|
617
|
+
} catch (err) {
|
|
618
|
+
this.logger?.warn?.("[SqliteTaskStore] purgeExpired failed", {
|
|
619
|
+
error: err instanceof Error ? err.message : String(err)
|
|
620
|
+
});
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
getDatabase() {
|
|
625
|
+
return this.db;
|
|
626
|
+
}
|
|
627
|
+
rowToRecord(row) {
|
|
628
|
+
const plaintext = this.encryptionKey ? decryptValue(this.encryptionKey, row.record_json) : row.record_json;
|
|
629
|
+
return JSON.parse(plaintext);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
|
|
633
|
+
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
|
|
634
|
+
*/
|
|
635
|
+
serializeRecord(record) {
|
|
636
|
+
const json = JSON.stringify(record);
|
|
637
|
+
return this.encryptionKey ? encryptValue(this.encryptionKey, json) : json;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
function encodeCursor(offset) {
|
|
641
|
+
return toB64Url(new TextEncoder().encode(JSON.stringify({ offset })));
|
|
642
|
+
}
|
|
643
|
+
function decodeCursor(cursor) {
|
|
644
|
+
try {
|
|
645
|
+
const bytes = fromB64Url(cursor);
|
|
646
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
647
|
+
if (typeof parsed?.offset === "number" && parsed.offset >= 0 && Number.isInteger(parsed.offset)) {
|
|
648
|
+
return parsed.offset;
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
return 0;
|
|
653
|
+
}
|
|
654
|
+
function toB64Url(data) {
|
|
655
|
+
const b64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(Array.from(data, (b) => String.fromCodePoint(b)).join(""));
|
|
656
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
657
|
+
}
|
|
658
|
+
function fromB64Url(data) {
|
|
659
|
+
let b64 = data.replace(/-/g, "+").replace(/_/g, "/");
|
|
660
|
+
while (b64.length % 4) b64 += "=";
|
|
661
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
|
|
662
|
+
const bin = atob(b64);
|
|
663
|
+
return Uint8Array.from(bin, (c) => c.codePointAt(0) ?? 0);
|
|
664
|
+
}
|
|
665
|
+
|
|
393
666
|
// libs/storage-sqlite/src/sqlite-event.store.ts
|
|
394
667
|
var SqliteEventStore = class {
|
|
395
668
|
db;
|
|
@@ -537,14 +810,14 @@ var SqliteEventStore = class {
|
|
|
537
810
|
};
|
|
538
811
|
|
|
539
812
|
// libs/storage-sqlite/src/sqlite.options.ts
|
|
540
|
-
var
|
|
541
|
-
var sqliteStorageOptionsSchema =
|
|
542
|
-
path:
|
|
543
|
-
encryption:
|
|
544
|
-
secret:
|
|
813
|
+
var import_lazy_zod = require("@frontmcp/lazy-zod");
|
|
814
|
+
var sqliteStorageOptionsSchema = import_lazy_zod.z.object({
|
|
815
|
+
path: import_lazy_zod.z.string().min(1),
|
|
816
|
+
encryption: import_lazy_zod.z.object({
|
|
817
|
+
secret: import_lazy_zod.z.string().min(1)
|
|
545
818
|
}).optional(),
|
|
546
|
-
ttlCleanupIntervalMs:
|
|
547
|
-
walMode:
|
|
819
|
+
ttlCleanupIntervalMs: import_lazy_zod.z.number().int().nonnegative().optional().default(6e4),
|
|
820
|
+
walMode: import_lazy_zod.z.boolean().optional().default(true)
|
|
548
821
|
});
|
|
549
822
|
// Annotate the CommonJS export names for ESM import in node:
|
|
550
823
|
0 && (module.exports = {
|
|
@@ -552,6 +825,7 @@ var sqliteStorageOptionsSchema = import_zod.z.object({
|
|
|
552
825
|
SqliteEventStore,
|
|
553
826
|
SqliteKvStore,
|
|
554
827
|
SqliteSessionStore,
|
|
828
|
+
SqliteTaskStore,
|
|
555
829
|
decryptValue,
|
|
556
830
|
deriveEncryptionKey,
|
|
557
831
|
encryptValue,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/storage-sqlite",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.1.0-beta.1",
|
|
4
4
|
"description": "SQLite storage backend for FrontMCP - local session, elicitation, and event persistence without Redis",
|
|
5
5
|
"author": "AgentFront <info@agentfront.dev>",
|
|
6
6
|
"homepage": "https://docs.agentfront.dev",
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"node": ">=24.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@frontmcp/utils": "1.0.
|
|
47
|
+
"@frontmcp/utils": "1.1.0-beta.1",
|
|
48
48
|
"better-sqlite3": "^12.6.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"zod": "
|
|
51
|
+
"@frontmcp/lazy-zod": "1.1.0-beta.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite Task Store
|
|
3
|
+
*
|
|
4
|
+
* Implements the FrontMCP `TaskStore` interface on top of better-sqlite3.
|
|
5
|
+
* Suitable for single-host deployments — including the CLI runner — where
|
|
6
|
+
* tasks outlive a single HTTP or stdio session but don't need cross-node
|
|
7
|
+
* pub/sub. Pub/sub is served by an in-process EventEmitter (same pattern as
|
|
8
|
+
* SqliteElicitationStore).
|
|
9
|
+
*
|
|
10
|
+
* Schema:
|
|
11
|
+
* ```sql
|
|
12
|
+
* CREATE TABLE IF NOT EXISTS mcp_tasks (
|
|
13
|
+
* task_id TEXT PRIMARY KEY,
|
|
14
|
+
* session_id TEXT NOT NULL,
|
|
15
|
+
* status TEXT NOT NULL,
|
|
16
|
+
* expires_at INTEGER NOT NULL,
|
|
17
|
+
* created_at INTEGER NOT NULL,
|
|
18
|
+
* updated_at INTEGER NOT NULL,
|
|
19
|
+
* executor_pid INTEGER,
|
|
20
|
+
* record_json TEXT NOT NULL
|
|
21
|
+
* );
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @module storage-sqlite/sqlite-task.store
|
|
25
|
+
*/
|
|
26
|
+
import type Database from 'better-sqlite3';
|
|
27
|
+
import type { SqliteStorageOptions } from './sqlite.options';
|
|
28
|
+
export interface TaskJsonRpcError {
|
|
29
|
+
code: number;
|
|
30
|
+
message: string;
|
|
31
|
+
data?: unknown;
|
|
32
|
+
}
|
|
33
|
+
export type TaskOutcome = {
|
|
34
|
+
kind: 'ok';
|
|
35
|
+
data: unknown;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'error';
|
|
38
|
+
error: TaskJsonRpcError;
|
|
39
|
+
};
|
|
40
|
+
export type TaskStatus = 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled';
|
|
41
|
+
export interface TaskRecord {
|
|
42
|
+
taskId: string;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
status: TaskStatus;
|
|
45
|
+
statusMessage?: string;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
lastUpdatedAt: string;
|
|
48
|
+
ttlMs: number | null;
|
|
49
|
+
pollIntervalMs?: number;
|
|
50
|
+
expiresAt: number;
|
|
51
|
+
request: {
|
|
52
|
+
method: 'tools/call';
|
|
53
|
+
params: Record<string, unknown>;
|
|
54
|
+
};
|
|
55
|
+
outcome?: TaskOutcome;
|
|
56
|
+
progressToken?: string | number;
|
|
57
|
+
executor?: {
|
|
58
|
+
host: 'in-process' | 'cli';
|
|
59
|
+
pid?: number;
|
|
60
|
+
spawnedAt?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export type TaskTerminalCallback = (record: TaskRecord) => void;
|
|
64
|
+
export type TaskCancelCallback = () => void;
|
|
65
|
+
export type TaskUnsubscribe = () => Promise<void>;
|
|
66
|
+
export interface TaskListPage {
|
|
67
|
+
tasks: TaskRecord[];
|
|
68
|
+
nextCursor?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface TaskStoreInterface {
|
|
71
|
+
create(record: TaskRecord): Promise<void>;
|
|
72
|
+
get(taskId: string, sessionId: string): Promise<TaskRecord | null>;
|
|
73
|
+
update(taskId: string, sessionId: string, patch: Partial<TaskRecord>): Promise<TaskRecord | null>;
|
|
74
|
+
delete(taskId: string, sessionId: string): Promise<void>;
|
|
75
|
+
list(sessionId: string, opts?: {
|
|
76
|
+
cursor?: string;
|
|
77
|
+
pageSize?: number;
|
|
78
|
+
}): Promise<TaskListPage>;
|
|
79
|
+
subscribeTerminal(taskId: string, sessionId: string, cb: TaskTerminalCallback): Promise<TaskUnsubscribe>;
|
|
80
|
+
publishTerminal(record: TaskRecord): Promise<void>;
|
|
81
|
+
subscribeCancel(taskId: string, sessionId: string, cb: TaskCancelCallback): Promise<TaskUnsubscribe>;
|
|
82
|
+
publishCancel(taskId: string, sessionId: string): Promise<void>;
|
|
83
|
+
destroy?(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export interface SqliteTaskLogger {
|
|
86
|
+
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
|
87
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
88
|
+
error?: (message: string, meta?: Record<string, unknown>) => void;
|
|
89
|
+
}
|
|
90
|
+
/** Returns `true` if the process with the given PID is alive. */
|
|
91
|
+
export type ProcessLivenessProbe = (pid: number) => boolean;
|
|
92
|
+
export interface SqliteTaskStoreOptions extends SqliteStorageOptions {
|
|
93
|
+
logger?: SqliteTaskLogger;
|
|
94
|
+
/**
|
|
95
|
+
* Override the PID liveness check (primarily for tests).
|
|
96
|
+
* Defaults to `process.kill(pid, 0)` semantics.
|
|
97
|
+
*/
|
|
98
|
+
livenessProbe?: ProcessLivenessProbe;
|
|
99
|
+
}
|
|
100
|
+
export declare class SqliteTaskStore implements TaskStoreInterface {
|
|
101
|
+
private readonly db;
|
|
102
|
+
private readonly emitter;
|
|
103
|
+
private readonly logger?;
|
|
104
|
+
private readonly liveness;
|
|
105
|
+
private readonly cleanupTimer;
|
|
106
|
+
/**
|
|
107
|
+
* AES-256-GCM key derived from `options.encryption.secret`, applied to the
|
|
108
|
+
* `record_json` column on write and reversed on read. Other columns
|
|
109
|
+
* (`task_id`, `session_id`, `status`, `expires_at`, `executor_pid`) are kept
|
|
110
|
+
* in plaintext so the indexes remain usable.
|
|
111
|
+
*/
|
|
112
|
+
private readonly encryptionKey;
|
|
113
|
+
private stmts;
|
|
114
|
+
constructor(options: SqliteTaskStoreOptions);
|
|
115
|
+
private initSchema;
|
|
116
|
+
private prepareStatements;
|
|
117
|
+
private prepared;
|
|
118
|
+
create(record: TaskRecord): Promise<void>;
|
|
119
|
+
get(taskId: string, sessionId: string): Promise<TaskRecord | null>;
|
|
120
|
+
update(taskId: string, sessionId: string, patch: Partial<TaskRecord>): Promise<TaskRecord | null>;
|
|
121
|
+
delete(taskId: string, sessionId: string): Promise<void>;
|
|
122
|
+
list(sessionId: string, opts?: {
|
|
123
|
+
cursor?: string;
|
|
124
|
+
pageSize?: number;
|
|
125
|
+
}): Promise<TaskListPage>;
|
|
126
|
+
subscribeTerminal(taskId: string, _sessionId: string, cb: TaskTerminalCallback): Promise<TaskUnsubscribe>;
|
|
127
|
+
publishTerminal(record: TaskRecord): Promise<void>;
|
|
128
|
+
subscribeCancel(taskId: string, _sessionId: string, cb: TaskCancelCallback): Promise<TaskUnsubscribe>;
|
|
129
|
+
publishCancel(taskId: string, _sessionId: string): Promise<void>;
|
|
130
|
+
destroy(): Promise<void>;
|
|
131
|
+
purgeExpired(): number;
|
|
132
|
+
getDatabase(): Database.Database;
|
|
133
|
+
private rowToRecord;
|
|
134
|
+
/**
|
|
135
|
+
* Serialize + optionally encrypt a TaskRecord before it lands in the DB.
|
|
136
|
+
* Only the `record_json` column is encrypted; indexed columns stay plaintext.
|
|
137
|
+
*/
|
|
138
|
+
private serializeRecord;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=sqlite-task.store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite-task.store.d.ts","sourceRoot":"","sources":["../src/sqlite-task.store.ts"],"names":[],"mappings":"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"}
|
package/sqlite.options.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Configuration types and Zod schema for SQLite-based storage.
|
|
5
5
|
*/
|
|
6
|
-
import { z } from 'zod';
|
|
6
|
+
import { z } from '@frontmcp/lazy-zod';
|
|
7
7
|
/**
|
|
8
8
|
* SQLite storage configuration options.
|
|
9
9
|
*/
|
|
@@ -32,14 +32,14 @@ export interface SqliteStorageOptions {
|
|
|
32
32
|
/**
|
|
33
33
|
* Zod schema for SQLite storage configuration.
|
|
34
34
|
*/
|
|
35
|
-
export declare const sqliteStorageOptionsSchema:
|
|
36
|
-
path:
|
|
37
|
-
encryption:
|
|
38
|
-
secret:
|
|
39
|
-
},
|
|
40
|
-
ttlCleanupIntervalMs:
|
|
41
|
-
walMode:
|
|
42
|
-
},
|
|
35
|
+
export declare const sqliteStorageOptionsSchema: import("@frontmcp/lazy-zod").ZodObject<{
|
|
36
|
+
path: import("@frontmcp/lazy-zod").ZodString;
|
|
37
|
+
encryption: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodObject<{
|
|
38
|
+
secret: import("@frontmcp/lazy-zod").ZodString;
|
|
39
|
+
}, import("zod/v4/core").$strip>>;
|
|
40
|
+
ttlCleanupIntervalMs: import("@frontmcp/lazy-zod").ZodDefault<import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>>;
|
|
41
|
+
walMode: import("@frontmcp/lazy-zod").ZodDefault<import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodBoolean>>;
|
|
42
|
+
}, import("zod/v4/core").$strip>;
|
|
43
43
|
/**
|
|
44
44
|
* SQLite storage input type (before Zod defaults).
|
|
45
45
|
*/
|
package/sqlite.options.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite.options.d.ts","sourceRoot":"","sources":["../src/sqlite.options.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"sqlite.options.d.ts","sourceRoot":"","sources":["../src/sqlite.options.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAC;AAEvC;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,UAAU,CAAC,EAAE;QACX,qEAAqE;QACrE,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IAEF;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,eAAO,MAAM,0BAA0B;;;;;;;gCASrC,CAAC;AAEH;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAEnF;;GAEG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC"}
|