@mobcode/openclaw-plugin 0.1.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.
@@ -0,0 +1,1430 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { DatabaseSync } from "node:sqlite";
5
+
6
+ function cloneJson(value) {
7
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
8
+ }
9
+
10
+ function nowMs() {
11
+ return Date.now();
12
+ }
13
+
14
+ function toJson(value) {
15
+ return JSON.stringify(value ?? null);
16
+ }
17
+
18
+ function fromJson(value, fallback) {
19
+ if (typeof value !== "string" || !value.trim()) {
20
+ return cloneJson(fallback);
21
+ }
22
+ try {
23
+ return JSON.parse(value);
24
+ } catch {
25
+ return cloneJson(fallback);
26
+ }
27
+ }
28
+
29
+ function normalizeSessionKey(rawSessionKey, rawSessionFile) {
30
+ const sessionKey = String(rawSessionKey ?? "").trim();
31
+ if (sessionKey) {
32
+ return sessionKey;
33
+ }
34
+ const sessionFile = String(rawSessionFile ?? "").trim();
35
+ return sessionFile || "unknown";
36
+ }
37
+
38
+ function normalizeMessageObject(message, messageId) {
39
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
40
+ return {
41
+ id: messageId ?? null,
42
+ role: "system",
43
+ content: [{ type: "text", text: String(message ?? "") }],
44
+ };
45
+ }
46
+ const normalized = cloneJson(message) ?? {};
47
+ const openclawMeta =
48
+ normalized.__openclaw && typeof normalized.__openclaw === "object"
49
+ ? normalized.__openclaw
50
+ : null;
51
+ const resolvedMessageId =
52
+ String(normalized.id ?? messageId ?? openclawMeta?.id ?? "").trim() || null;
53
+ if (resolvedMessageId && !normalized.id) {
54
+ normalized.id = resolvedMessageId;
55
+ }
56
+ return normalized;
57
+ }
58
+
59
+ function extractMessageText(message) {
60
+ const content = message?.content;
61
+ if (typeof content === "string") {
62
+ return content;
63
+ }
64
+ if (!Array.isArray(content)) {
65
+ return "";
66
+ }
67
+ return content
68
+ .map((block) => {
69
+ if (!block || typeof block !== "object") {
70
+ return "";
71
+ }
72
+ if (typeof block.text === "string") {
73
+ return block.text;
74
+ }
75
+ if (typeof block.content === "string") {
76
+ return block.content;
77
+ }
78
+ return "";
79
+ })
80
+ .filter(Boolean)
81
+ .join("\n");
82
+ }
83
+
84
+ function extractThinkingText(message) {
85
+ const content = message?.content;
86
+ if (!Array.isArray(content)) {
87
+ return null;
88
+ }
89
+ const parts = [];
90
+ for (const block of content) {
91
+ if (!block || typeof block !== "object") continue;
92
+ if (String(block.type ?? "").trim().toLowerCase() !== "thinking") {
93
+ continue;
94
+ }
95
+ const value = typeof block.thinking === "string" ? block.thinking : "";
96
+ if (value.trim()) {
97
+ parts.push(value);
98
+ }
99
+ }
100
+ return parts.length > 0 ? parts.join("\n") : null;
101
+ }
102
+
103
+ function textToDisplayForAssistant(text) {
104
+ const normalized = typeof text === "string" ? text : String(text ?? "");
105
+ const trimmed = normalized.trim();
106
+ if (!trimmed || !trimmed.startsWith("{")) {
107
+ return normalized;
108
+ }
109
+ try {
110
+ const parsed = JSON.parse(trimmed);
111
+ if (
112
+ parsed &&
113
+ typeof parsed === "object" &&
114
+ !Array.isArray(parsed) &&
115
+ ((Object.prototype.hasOwnProperty.call(parsed, "ok") &&
116
+ Object.prototype.hasOwnProperty.call(parsed, "result")) ||
117
+ Object.prototype.hasOwnProperty.call(parsed, "restart") ||
118
+ Object.prototype.hasOwnProperty.call(parsed, "sentinel"))
119
+ ) {
120
+ return "";
121
+ }
122
+ } catch {
123
+ return normalized;
124
+ }
125
+ return normalized;
126
+ }
127
+
128
+ function decodeMobcodePayload(value) {
129
+ if (!value) return null;
130
+ if (typeof value === "string") {
131
+ const trimmed = value.trim();
132
+ if (!trimmed || !trimmed.startsWith("{")) {
133
+ return null;
134
+ }
135
+ try {
136
+ const decoded = JSON.parse(trimmed);
137
+ return decoded && typeof decoded === "object" && !Array.isArray(decoded) ? decoded : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+ if (Array.isArray(value)) {
143
+ for (const block of value) {
144
+ if (!block || typeof block !== "object") continue;
145
+ const type = String(block.type ?? "").trim().toLowerCase();
146
+ if (type !== "text" && type !== "output_text" && type !== "input_text") {
147
+ continue;
148
+ }
149
+ const decoded = decodeMobcodePayload(block.text ?? block.content ?? "");
150
+ if (decoded) return decoded;
151
+ }
152
+ return null;
153
+ }
154
+ if (typeof value === "object" && !Array.isArray(value)) {
155
+ return value;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function extractMobcodeContentBlocksFromToolResult(rawResult) {
161
+ const payload = decodeMobcodePayload(rawResult);
162
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
163
+ return [];
164
+ }
165
+ return Array.isArray(payload.content_blocks)
166
+ ? cloneJson(payload.content_blocks) ?? []
167
+ : [];
168
+ }
169
+
170
+ function timelineTextPreview(timeline) {
171
+ if (!Array.isArray(timeline) || timeline.length === 0) {
172
+ return "";
173
+ }
174
+ const parts = [];
175
+ for (const item of timeline) {
176
+ if (!item || typeof item !== "object") continue;
177
+ const kind = String(item.kind ?? "").trim().toLowerCase();
178
+ if (kind === "text") {
179
+ const text = String(item.text ?? "");
180
+ if (text.trim()) parts.push(text);
181
+ continue;
182
+ }
183
+ if (kind === "step") {
184
+ const title = String(item.title ?? "");
185
+ const detail = String(item.detail ?? "");
186
+ if (title.trim()) parts.push(title);
187
+ if (detail.trim()) parts.push(detail);
188
+ continue;
189
+ }
190
+ if (kind === "artifact") {
191
+ const title = String(item?.block?.title ?? "");
192
+ if (title.trim()) parts.push(title);
193
+ continue;
194
+ }
195
+ if (kind === "question") {
196
+ const title = String(item.title ?? item?.payload?.title ?? "");
197
+ if (title.trim()) parts.push(title);
198
+ }
199
+ }
200
+ return parts.join("\n");
201
+ }
202
+
203
+ function stableId(prefix, seed) {
204
+ const digest = crypto.createHash("sha1").update(`${prefix}\n${seed}`).digest("hex");
205
+ return `${prefix}-${digest.slice(0, 12)}`;
206
+ }
207
+
208
+ function createTextTimelineItem({ id, text, status = "done" }) {
209
+ return {
210
+ id,
211
+ kind: "text",
212
+ status,
213
+ text,
214
+ };
215
+ }
216
+
217
+ function createToolTimelineItem({ id, toolCall }) {
218
+ const status = toolCall.isError
219
+ ? "error"
220
+ : toolCall.phase === "result"
221
+ ? "done"
222
+ : "streaming";
223
+ return {
224
+ id,
225
+ kind: "tool",
226
+ status,
227
+ toolCallId: toolCall.toolCallId,
228
+ ...(toolCall.approvalId ? { approvalId: toolCall.approvalId } : {}),
229
+ name: toolCall.name,
230
+ args: cloneJson(toolCall.args),
231
+ result: cloneJson(toolCall.result),
232
+ isError: toolCall.isError === true,
233
+ phase: toolCall.phase,
234
+ };
235
+ }
236
+
237
+ function timelineItemFromContentBlock(block, seed) {
238
+ if (!block || typeof block !== "object") {
239
+ return null;
240
+ }
241
+ const type = String(block.type ?? "").trim().toLowerCase();
242
+ if (type === "text") {
243
+ const text = String(block.text ?? "");
244
+ if (!text.trim()) return null;
245
+ return createTextTimelineItem({
246
+ id: stableId("timeline-text", `${seed}\n${text}`),
247
+ text,
248
+ status: "done",
249
+ });
250
+ }
251
+ if (type === "artifact_ref") {
252
+ return {
253
+ id: stableId("timeline-artifact", `${seed}\n${JSON.stringify(block)}`),
254
+ kind: "artifact",
255
+ status: "done",
256
+ block: cloneJson(block),
257
+ };
258
+ }
259
+ if (type === "question") {
260
+ return {
261
+ id: stableId("timeline-question", `${seed}\n${JSON.stringify(block)}`),
262
+ kind: "question",
263
+ status: "done",
264
+ payload: cloneJson(block),
265
+ ...cloneJson(block),
266
+ };
267
+ }
268
+ return {
269
+ id: stableId("timeline-unknown", `${seed}\n${JSON.stringify(block)}`),
270
+ kind: "unknown",
271
+ status: "error",
272
+ block: cloneJson(block),
273
+ };
274
+ }
275
+
276
+ function extractArtifactDocumentFromMessage(message) {
277
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
278
+ return null;
279
+ }
280
+ const directPayload = decodeMobcodePayload(message.content);
281
+ if (directPayload?.artifact && typeof directPayload.artifact === "object") {
282
+ return directPayload.artifact;
283
+ }
284
+ if (Array.isArray(message.content)) {
285
+ for (const block of message.content) {
286
+ if (!block || typeof block !== "object") continue;
287
+ if (block.type === "toolresult" || block.type === "tool_result") {
288
+ const payload = decodeMobcodePayload(block.content ?? block.result ?? block.output);
289
+ if (payload?.artifact && typeof payload.artifact === "object") {
290
+ return payload.artifact;
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+
298
+ function createConversationMessage({ id, role, text = "", replyToId = null, replyPreview = null }) {
299
+ return {
300
+ id,
301
+ role,
302
+ text,
303
+ status: "done",
304
+ toolCalls: [],
305
+ contentBlocks: [],
306
+ timeline: [],
307
+ ...(replyToId ? { replyToId } : {}),
308
+ ...(replyPreview ? { replyPreview } : {}),
309
+ };
310
+ }
311
+
312
+ function upsertToolCall(message, toolCall, seed) {
313
+ const toolCalls = Array.isArray(message.toolCalls) ? [...message.toolCalls] : [];
314
+ const toolIndex = toolCalls.findIndex((item) => item?.toolCallId === toolCall.toolCallId);
315
+ if (toolIndex >= 0) {
316
+ toolCalls[toolIndex] = { ...toolCalls[toolIndex], ...cloneJson(toolCall) };
317
+ } else {
318
+ toolCalls.push(cloneJson(toolCall));
319
+ }
320
+
321
+ const timeline = Array.isArray(message.timeline) ? [...message.timeline] : [];
322
+ const timelineIndex = timeline.findIndex(
323
+ (item) => item?.kind === "tool" && item?.toolCallId === toolCall.toolCallId,
324
+ );
325
+ const toolItem = createToolTimelineItem({
326
+ id: timelineIndex >= 0 ? timeline[timelineIndex].id : stableId("timeline-tool", seed),
327
+ toolCall,
328
+ });
329
+ if (timelineIndex >= 0) {
330
+ timeline[timelineIndex] = toolItem;
331
+ } else {
332
+ timeline.push(toolItem);
333
+ }
334
+
335
+ return {
336
+ ...message,
337
+ toolCalls,
338
+ timeline,
339
+ text: timelineTextPreview(timeline) || message.text || "",
340
+ };
341
+ }
342
+
343
+ function appendAssistantTextSegment(message, text, seed) {
344
+ if (!String(text ?? "").trim()) {
345
+ return message;
346
+ }
347
+ const normalizedText = String(text);
348
+ const timeline = Array.isArray(message.timeline) ? [...message.timeline] : [];
349
+ const last = timeline.length > 0 ? timeline[timeline.length - 1] : null;
350
+ if (last?.kind === "text" && last?.status === "streaming") {
351
+ timeline[timeline.length - 1] = {
352
+ ...last,
353
+ text: `${String(last.text ?? "")}${normalizedText}`,
354
+ status: "done",
355
+ };
356
+ } else {
357
+ timeline.push(
358
+ createTextTimelineItem({
359
+ id: stableId("timeline-text", `${seed}\n${timeline.length}`),
360
+ text: normalizedText,
361
+ status: "done",
362
+ }),
363
+ );
364
+ }
365
+ return {
366
+ ...message,
367
+ timeline,
368
+ text: timelineTextPreview(timeline) || message.text || "",
369
+ };
370
+ }
371
+
372
+ function appendToolResult(message, { toolCallId, result, isError }, seed) {
373
+ const existingTool =
374
+ (Array.isArray(message.toolCalls)
375
+ ? message.toolCalls.find((item) => item?.toolCallId === toolCallId)
376
+ : null) ?? {
377
+ toolCallId,
378
+ name: "unknown",
379
+ phase: "result",
380
+ result: null,
381
+ isError: false,
382
+ };
383
+ let next = upsertToolCall(
384
+ message,
385
+ {
386
+ ...existingTool,
387
+ result: cloneJson(result),
388
+ isError: isError === true,
389
+ phase: "result",
390
+ },
391
+ `${seed}\n${toolCallId}`,
392
+ );
393
+
394
+ const blocks = extractMobcodeContentBlocksFromToolResult(result);
395
+ if (blocks.length === 0) {
396
+ return next;
397
+ }
398
+
399
+ const contentBlocks = Array.isArray(next.contentBlocks) ? [...next.contentBlocks] : [];
400
+ const timeline = Array.isArray(next.timeline) ? [...next.timeline] : [];
401
+ const seenBlockKeys = new Set(contentBlocks.map((block) => JSON.stringify(block)));
402
+ const seenTimelineKeys = new Set(timeline.map((item) => JSON.stringify(item)));
403
+ for (const block of blocks) {
404
+ const blockKey = JSON.stringify(block);
405
+ if (!seenBlockKeys.has(blockKey)) {
406
+ seenBlockKeys.add(blockKey);
407
+ contentBlocks.push(cloneJson(block));
408
+ }
409
+ const item = timelineItemFromContentBlock(block, `${seed}\n${blockKey}`);
410
+ if (!item) continue;
411
+ const timelineKey = JSON.stringify(item);
412
+ if (!seenTimelineKeys.has(timelineKey)) {
413
+ seenTimelineKeys.add(timelineKey);
414
+ timeline.push(item);
415
+ }
416
+ }
417
+ return {
418
+ ...next,
419
+ contentBlocks,
420
+ timeline,
421
+ text: timelineTextPreview(timeline) || next.text || "",
422
+ };
423
+ }
424
+
425
+ function appendAssistantTranscriptContent(message, content, seed) {
426
+ if (!Array.isArray(content)) {
427
+ return appendAssistantTextSegment(
428
+ message,
429
+ textToDisplayForAssistant(extractMessageText({ content })),
430
+ seed,
431
+ );
432
+ }
433
+
434
+ let next = { ...message };
435
+ for (const rawBlock of content) {
436
+ if (!rawBlock || typeof rawBlock !== "object") continue;
437
+ const block = cloneJson(rawBlock) ?? {};
438
+ const type = String(block.type ?? "").trim().toLowerCase();
439
+ if (type === "text" || type === "output_text" || type === "input_text") {
440
+ next = appendAssistantTextSegment(
441
+ next,
442
+ textToDisplayForAssistant(String(block.text ?? block.content ?? "")),
443
+ `${seed}\ntext`,
444
+ );
445
+ continue;
446
+ }
447
+ if (type === "toolcall" || type === "tool_call") {
448
+ const toolCallId = String(block.id ?? "").trim();
449
+ if (!toolCallId) continue;
450
+ next = upsertToolCall(
451
+ next,
452
+ {
453
+ toolCallId,
454
+ approvalId: String(block.approvalId ?? block.approval_id ?? "").trim() || null,
455
+ name: String(block.name ?? "unknown"),
456
+ args: cloneJson(block.arguments ?? block.args),
457
+ result: null,
458
+ isError: false,
459
+ phase: "start",
460
+ },
461
+ `${seed}\n${toolCallId}`,
462
+ );
463
+ continue;
464
+ }
465
+ if (type === "toolresult" || type === "tool_result") {
466
+ const toolCallId = String(block.toolCallId ?? block.tool_call_id ?? "").trim();
467
+ if (!toolCallId) continue;
468
+ next = appendToolResult(
469
+ next,
470
+ {
471
+ toolCallId,
472
+ result: block.content ?? block.result ?? block.output,
473
+ isError: block.isError === true || block.is_error === true,
474
+ },
475
+ `${seed}\n${toolCallId}`,
476
+ );
477
+ continue;
478
+ }
479
+ }
480
+ return next;
481
+ }
482
+
483
+ function projectConversationMessages(rawMessages, sessionKey) {
484
+ const projected = [];
485
+ const assistantIndexByRunId = new Map();
486
+
487
+ for (const rawMessage of rawMessages) {
488
+ const message = normalizeMessageObject(rawMessage, rawMessage?.id ?? null);
489
+ const role = String(message?.role ?? "").trim().toLowerCase();
490
+ const runId = String(message?.runId ?? message?.run_id ?? "").trim();
491
+ const messageId =
492
+ String(message?.id ?? "").trim() ||
493
+ stableId("message", `${sessionKey}\n${role}\n${JSON.stringify(message)}`);
494
+
495
+ if (role === "user" || role === "system") {
496
+ projected.push(
497
+ createConversationMessage({
498
+ id: messageId,
499
+ role,
500
+ text: extractMessageText(message),
501
+ }),
502
+ );
503
+ continue;
504
+ }
505
+
506
+ if (role === "assistant") {
507
+ const assistantIndex = runId ? assistantIndexByRunId.get(runId) : undefined;
508
+ const current =
509
+ assistantIndex == null
510
+ ? createConversationMessage({
511
+ id: messageId,
512
+ role: "assistant",
513
+ text: "",
514
+ })
515
+ : projected[assistantIndex];
516
+ let next = appendAssistantTranscriptContent(
517
+ {
518
+ ...current,
519
+ thinking: extractThinkingText(message) ?? current.thinking ?? null,
520
+ },
521
+ message.content,
522
+ `${sessionKey}\n${messageId}`,
523
+ );
524
+ if (assistantIndex == null) {
525
+ projected.push(next);
526
+ if (runId) {
527
+ assistantIndexByRunId.set(runId, projected.length - 1);
528
+ }
529
+ } else {
530
+ projected[assistantIndex] = next;
531
+ }
532
+ continue;
533
+ }
534
+
535
+ if (role === "toolresult" || role === "tool_result") {
536
+ const toolCallId = String(message?.toolCallId ?? message?.tool_call_id ?? "").trim();
537
+ if (!toolCallId) {
538
+ continue;
539
+ }
540
+ const assistantIndex = runId
541
+ ? assistantIndexByRunId.get(runId)
542
+ : projected.map((item) => item.role).lastIndexOf("assistant");
543
+ if (assistantIndex == null || assistantIndex < 0) {
544
+ continue;
545
+ }
546
+ projected[assistantIndex] = appendToolResult(
547
+ projected[assistantIndex],
548
+ {
549
+ toolCallId,
550
+ result: decodeMobcodePayload(message.content) ?? message.content,
551
+ isError: message.isError === true || message.is_error === true,
552
+ },
553
+ `${sessionKey}\n${messageId}`,
554
+ );
555
+ }
556
+ }
557
+
558
+ return projected.map((item) => ({
559
+ ...item,
560
+ status: "done",
561
+ text: timelineTextPreview(item.timeline) || item.text || "",
562
+ }));
563
+ }
564
+
565
+ function createMessageSourceKey(sessionKey, messageId, normalizedMessage) {
566
+ if (messageId) {
567
+ return `mid:${sessionKey}:${messageId}`;
568
+ }
569
+ const digest = crypto
570
+ .createHash("sha256")
571
+ .update(`${sessionKey}\n${JSON.stringify(normalizedMessage)}`)
572
+ .digest("hex");
573
+ return `hash:${digest}`;
574
+ }
575
+
576
+ function normalizeApprovalDecision(decision) {
577
+ const normalized = String(decision ?? "").trim().toLowerCase();
578
+ switch (normalized) {
579
+ case "allow-once":
580
+ return "allow-once";
581
+ case "allow-always":
582
+ return "allow-always";
583
+ case "deny":
584
+ return "deny";
585
+ default:
586
+ return null;
587
+ }
588
+ }
589
+
590
+ function normalizeApprovalStatus(status, decision) {
591
+ if (status === "pending") {
592
+ return "pending";
593
+ }
594
+ const normalizedDecision = normalizeApprovalDecision(decision);
595
+ return normalizedDecision ?? "resolved";
596
+ }
597
+
598
+ export class MobcodeStateStore {
599
+ constructor({ rootDir }) {
600
+ this.rootDir = rootDir;
601
+ this.databasePath = path.join(rootDir, "mobcode.sqlite");
602
+ this.database = null;
603
+ this.clientActionWaiters = new Map();
604
+ this.clientActionResolutionWaiters = new Map();
605
+ }
606
+
607
+ async init() {
608
+ await fs.mkdir(this.rootDir, { recursive: true });
609
+ if (this.database) {
610
+ return;
611
+ }
612
+ this.database = new DatabaseSync(this.databasePath);
613
+ this.database.exec(`
614
+ PRAGMA journal_mode = WAL;
615
+ PRAGMA foreign_keys = ON;
616
+
617
+ CREATE TABLE IF NOT EXISTS devices (
618
+ platform TEXT NOT NULL,
619
+ token TEXT NOT NULL,
620
+ metadata_json TEXT NOT NULL DEFAULT '{}',
621
+ updated_at TEXT NOT NULL,
622
+ PRIMARY KEY (platform, token)
623
+ );
624
+
625
+ CREATE TABLE IF NOT EXISTS push_queue (
626
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
627
+ type TEXT NOT NULL,
628
+ session_key TEXT,
629
+ payload_json TEXT NOT NULL,
630
+ queued_at TEXT NOT NULL
631
+ );
632
+
633
+ CREATE TABLE IF NOT EXISTS indexed_sessions (
634
+ session_key TEXT PRIMARY KEY,
635
+ last_backfill_at TEXT,
636
+ updated_at TEXT NOT NULL
637
+ );
638
+
639
+ CREATE TABLE IF NOT EXISTS messages (
640
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
641
+ session_key TEXT NOT NULL,
642
+ source_key TEXT NOT NULL UNIQUE,
643
+ message_id TEXT,
644
+ role TEXT,
645
+ text TEXT,
646
+ raw_json TEXT NOT NULL,
647
+ created_at_ms INTEGER NOT NULL,
648
+ indexed_at TEXT NOT NULL
649
+ );
650
+
651
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id_desc
652
+ ON messages(session_key, id DESC);
653
+
654
+ CREATE TABLE IF NOT EXISTS approvals (
655
+ approval_id TEXT PRIMARY KEY,
656
+ session_key TEXT,
657
+ command TEXT NOT NULL,
658
+ cwd TEXT,
659
+ host TEXT,
660
+ security TEXT,
661
+ ask TEXT,
662
+ created_at_ms INTEGER NOT NULL,
663
+ expires_at_ms INTEGER NOT NULL,
664
+ status TEXT NOT NULL,
665
+ decision TEXT,
666
+ resolved_at_ms INTEGER,
667
+ request_json TEXT NOT NULL,
668
+ updated_at TEXT NOT NULL
669
+ );
670
+
671
+ CREATE INDEX IF NOT EXISTS idx_approvals_session_created
672
+ ON approvals(session_key, created_at_ms DESC);
673
+
674
+ CREATE INDEX IF NOT EXISTS idx_approvals_status_created
675
+ ON approvals(status, created_at_ms DESC);
676
+
677
+ CREATE TABLE IF NOT EXISTS artifacts (
678
+ artifact_id TEXT PRIMARY KEY,
679
+ session_key TEXT,
680
+ run_id TEXT,
681
+ kind TEXT NOT NULL,
682
+ title TEXT NOT NULL,
683
+ summary TEXT,
684
+ document_json TEXT NOT NULL,
685
+ created_at_ms INTEGER NOT NULL,
686
+ updated_at_ms INTEGER NOT NULL
687
+ );
688
+
689
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session_updated
690
+ ON artifacts(session_key, updated_at_ms DESC);
691
+
692
+ CREATE TABLE IF NOT EXISTS artifact_blobs (
693
+ blob_id TEXT PRIMARY KEY,
694
+ artifact_id TEXT NOT NULL,
695
+ mime_type TEXT,
696
+ file_name TEXT,
697
+ storage_path TEXT NOT NULL,
698
+ created_at_ms INTEGER NOT NULL,
699
+ updated_at_ms INTEGER NOT NULL,
700
+ FOREIGN KEY (artifact_id) REFERENCES artifacts(artifact_id) ON DELETE CASCADE
701
+ );
702
+
703
+ CREATE INDEX IF NOT EXISTS idx_artifact_blobs_artifact_updated
704
+ ON artifact_blobs(artifact_id, updated_at_ms DESC);
705
+
706
+ CREATE TABLE IF NOT EXISTS client_sessions (
707
+ client_id TEXT PRIMARY KEY,
708
+ session_key TEXT,
709
+ platform TEXT,
710
+ app_version TEXT,
711
+ capabilities_json TEXT NOT NULL,
712
+ last_seen_at_ms INTEGER NOT NULL,
713
+ updated_at_ms INTEGER NOT NULL
714
+ );
715
+
716
+ CREATE INDEX IF NOT EXISTS idx_client_sessions_seen
717
+ ON client_sessions(last_seen_at_ms DESC);
718
+
719
+ CREATE TABLE IF NOT EXISTS client_actions (
720
+ action_id TEXT PRIMARY KEY,
721
+ client_id TEXT NOT NULL,
722
+ type TEXT NOT NULL,
723
+ payload_json TEXT NOT NULL,
724
+ status TEXT NOT NULL,
725
+ created_at_ms INTEGER NOT NULL,
726
+ acked_at_ms INTEGER,
727
+ error_text TEXT,
728
+ FOREIGN KEY (client_id) REFERENCES client_sessions(client_id) ON DELETE CASCADE
729
+ );
730
+
731
+ CREATE INDEX IF NOT EXISTS idx_client_actions_client_status_created
732
+ ON client_actions(client_id, status, created_at_ms ASC);
733
+ `);
734
+ }
735
+
736
+ _db() {
737
+ if (!this.database) {
738
+ throw new Error("MobCode store not initialized");
739
+ }
740
+ return this.database;
741
+ }
742
+
743
+ async readDevices() {
744
+ const rows = this._db()
745
+ .prepare(
746
+ `SELECT platform, token, metadata_json, updated_at
747
+ FROM devices
748
+ ORDER BY updated_at DESC`,
749
+ )
750
+ .all();
751
+ return {
752
+ devices: rows.map((row) => ({
753
+ platform: row.platform,
754
+ token: row.token,
755
+ ...(fromJson(row.metadata_json, {}) ?? {}),
756
+ updatedAt: row.updated_at,
757
+ })),
758
+ };
759
+ }
760
+
761
+ async upsertDevice(device) {
762
+ const platform = String(device?.platform ?? "").trim();
763
+ const token = String(device?.token ?? "").trim();
764
+ if (!platform || !token) {
765
+ throw new Error("platform and token are required");
766
+ }
767
+ const now = new Date().toISOString();
768
+ const metadata = { ...device };
769
+ delete metadata.platform;
770
+ delete metadata.token;
771
+ this._db()
772
+ .prepare(
773
+ `INSERT INTO devices(platform, token, metadata_json, updated_at)
774
+ VALUES(?, ?, ?, ?)
775
+ ON CONFLICT(platform, token) DO UPDATE SET
776
+ metadata_json=excluded.metadata_json,
777
+ updated_at=excluded.updated_at`,
778
+ )
779
+ .run(platform, token, toJson(metadata), now);
780
+ return this.readDevices();
781
+ }
782
+
783
+ async enqueuePush(item) {
784
+ const queuedAt = new Date().toISOString();
785
+ this._db()
786
+ .prepare(
787
+ `INSERT INTO push_queue(type, session_key, payload_json, queued_at)
788
+ VALUES(?, ?, ?, ?)`,
789
+ )
790
+ .run(
791
+ String(item?.type ?? "unknown"),
792
+ item?.sessionKey ? String(item.sessionKey) : null,
793
+ toJson(item),
794
+ queuedAt,
795
+ );
796
+ const row = this._db().prepare(`SELECT COUNT(*) AS count FROM push_queue`).get();
797
+ return { queued: Number(row?.count ?? 0) };
798
+ }
799
+
800
+ async ensureSessionIndexed(sessionKey, loader) {
801
+ const normalizedSessionKey = String(sessionKey ?? "").trim();
802
+ if (!normalizedSessionKey) {
803
+ return;
804
+ }
805
+ const existing = this._db()
806
+ .prepare(`SELECT session_key FROM indexed_sessions WHERE session_key=? LIMIT 1`)
807
+ .get(normalizedSessionKey);
808
+ if (existing) {
809
+ return;
810
+ }
811
+ const messages = Array.isArray(loader) ? loader : await loader();
812
+ await this.indexSessionMessages(normalizedSessionKey, messages);
813
+ const now = new Date().toISOString();
814
+ this._db()
815
+ .prepare(
816
+ `INSERT INTO indexed_sessions(session_key, last_backfill_at, updated_at)
817
+ VALUES(?, ?, ?)
818
+ ON CONFLICT(session_key) DO UPDATE SET
819
+ last_backfill_at=excluded.last_backfill_at,
820
+ updated_at=excluded.updated_at`,
821
+ )
822
+ .run(normalizedSessionKey, now, now);
823
+ }
824
+
825
+ async indexSessionMessages(sessionKey, messages) {
826
+ const normalizedSessionKey = String(sessionKey ?? "").trim();
827
+ if (!normalizedSessionKey || !Array.isArray(messages) || messages.length == 0) {
828
+ return;
829
+ }
830
+
831
+ const insert = this._db().prepare(
832
+ `INSERT OR IGNORE INTO messages(
833
+ session_key,
834
+ source_key,
835
+ message_id,
836
+ role,
837
+ text,
838
+ raw_json,
839
+ created_at_ms,
840
+ indexed_at
841
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
842
+ );
843
+ const touchSession = this._db().prepare(
844
+ `INSERT INTO indexed_sessions(session_key, updated_at)
845
+ VALUES(?, ?)
846
+ ON CONFLICT(session_key) DO UPDATE SET updated_at=excluded.updated_at`,
847
+ );
848
+ const upsertArtifact = this._db().prepare(
849
+ `INSERT INTO artifacts(
850
+ artifact_id,
851
+ session_key,
852
+ run_id,
853
+ kind,
854
+ title,
855
+ summary,
856
+ document_json,
857
+ created_at_ms,
858
+ updated_at_ms
859
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
860
+ ON CONFLICT(artifact_id) DO UPDATE SET
861
+ session_key=excluded.session_key,
862
+ run_id=excluded.run_id,
863
+ kind=excluded.kind,
864
+ title=excluded.title,
865
+ summary=excluded.summary,
866
+ document_json=excluded.document_json,
867
+ updated_at_ms=excluded.updated_at_ms`,
868
+ );
869
+ const now = new Date().toISOString();
870
+
871
+ const transaction = this._db().transaction((items) => {
872
+ for (const item of items) {
873
+ const normalizedMessage = normalizeMessageObject(item, item?.id ?? null);
874
+ const messageId = String(normalizedMessage?.id ?? "").trim() || null;
875
+ const sourceKey = createMessageSourceKey(
876
+ normalizedSessionKey,
877
+ messageId,
878
+ normalizedMessage,
879
+ );
880
+ const timestampRaw =
881
+ normalizedMessage?.timestamp ?? normalizedMessage?.createdAt ?? Date.now();
882
+ const createdAtMs =
883
+ typeof timestampRaw === "number"
884
+ ? timestampRaw
885
+ : Number.parseInt(String(timestampRaw), 10) || Date.now();
886
+
887
+ insert.run(
888
+ normalizedSessionKey,
889
+ sourceKey,
890
+ messageId,
891
+ String(normalizedMessage?.role ?? "").trim() || null,
892
+ extractMessageText(normalizedMessage),
893
+ toJson(normalizedMessage),
894
+ createdAtMs,
895
+ now,
896
+ );
897
+ const artifactDocument = extractArtifactDocumentFromMessage(normalizedMessage);
898
+ const artifactId = String(
899
+ artifactDocument?.artifactId ?? artifactDocument?.artifact_id ?? "",
900
+ ).trim();
901
+ const artifactKind = String(artifactDocument?.kind ?? "").trim();
902
+ const artifactTitle = String(artifactDocument?.title ?? "").trim();
903
+ if (artifactId && artifactKind && artifactTitle) {
904
+ upsertArtifact.run(
905
+ artifactId,
906
+ normalizedSessionKey,
907
+ String(normalizedMessage?.runId ?? normalizedMessage?.run_id ?? "").trim() || null,
908
+ artifactKind,
909
+ artifactTitle,
910
+ String(artifactDocument?.summary ?? "").trim() || null,
911
+ toJson(artifactDocument),
912
+ createdAtMs,
913
+ createdAtMs,
914
+ );
915
+ }
916
+ }
917
+ touchSession.run(normalizedSessionKey, now);
918
+ });
919
+
920
+ transaction(messages);
921
+ }
922
+
923
+ async appendMessage(update) {
924
+ const sessionKey = normalizeSessionKey(update?.sessionKey, update?.sessionFile);
925
+ const normalizedMessage = normalizeMessageObject(update?.message, update?.messageId);
926
+ await this.indexSessionMessages(sessionKey, [normalizedMessage]);
927
+ const row = this._db()
928
+ .prepare(`SELECT COUNT(*) AS count FROM messages WHERE session_key=?`)
929
+ .get(sessionKey);
930
+ return { sessionKey, count: Number(row?.count ?? 0) };
931
+ }
932
+
933
+ async pageTranscriptMessages(sessionKey) {
934
+ const normalizedSessionKey = String(sessionKey ?? "").trim();
935
+ const rows = this._db()
936
+ .prepare(
937
+ `SELECT id, message_id, raw_json
938
+ FROM messages
939
+ WHERE session_key=?
940
+ ORDER BY id ASC`,
941
+ )
942
+ .all(normalizedSessionKey);
943
+ return rows.map((row) => {
944
+ const message = fromJson(row.raw_json, {});
945
+ if (message && typeof message === "object" && !Array.isArray(message) && !message.id) {
946
+ message.id = row.message_id ?? null;
947
+ }
948
+ return message;
949
+ });
950
+ }
951
+
952
+ async pageSessionMessages(sessionKey, { limit = 100, beforeId } = {}) {
953
+ const normalizedSessionKey = String(sessionKey ?? "").trim();
954
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 100, 500));
955
+ const normalizedBeforeId =
956
+ typeof beforeId === "number" && Number.isFinite(beforeId) ? beforeId : null;
957
+ const transcriptMessages = await this.pageTranscriptMessages(normalizedSessionKey);
958
+ const projected = projectConversationMessages(transcriptMessages, normalizedSessionKey);
959
+ const endExclusive = normalizedBeforeId == null
960
+ ? projected.length
961
+ : Math.max(0, Math.min(projected.length, normalizedBeforeId - 1));
962
+ const start = Math.max(0, endExclusive - normalizedLimit);
963
+ const items = projected.slice(start, endExclusive);
964
+ const hasMore = start > 0;
965
+ const nextBeforeId = hasMore ? start + 1 : null;
966
+
967
+ return {
968
+ sessionKey: normalizedSessionKey,
969
+ items,
970
+ rawMessages: transcriptMessages,
971
+ nextBeforeId,
972
+ hasMore,
973
+ total: projected.length,
974
+ };
975
+ }
976
+
977
+ async listSessionMessages(sessionKey) {
978
+ const page = await this.pageSessionMessages(sessionKey, { limit: 500 });
979
+ return page.items;
980
+ }
981
+
982
+ async recordApprovalRequested(payload) {
983
+ const request = payload?.request && typeof payload.request === "object" ? payload.request : {};
984
+ const approvalId = String(payload?.id ?? "").trim();
985
+ if (!approvalId) {
986
+ return;
987
+ }
988
+ const now = new Date().toISOString();
989
+ this._db()
990
+ .prepare(
991
+ `INSERT INTO approvals(
992
+ approval_id,
993
+ session_key,
994
+ command,
995
+ cwd,
996
+ host,
997
+ security,
998
+ ask,
999
+ created_at_ms,
1000
+ expires_at_ms,
1001
+ status,
1002
+ decision,
1003
+ resolved_at_ms,
1004
+ request_json,
1005
+ updated_at
1006
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)
1007
+ ON CONFLICT(approval_id) DO UPDATE SET
1008
+ session_key=excluded.session_key,
1009
+ command=excluded.command,
1010
+ cwd=excluded.cwd,
1011
+ host=excluded.host,
1012
+ security=excluded.security,
1013
+ ask=excluded.ask,
1014
+ created_at_ms=excluded.created_at_ms,
1015
+ expires_at_ms=excluded.expires_at_ms,
1016
+ status='pending',
1017
+ decision=NULL,
1018
+ resolved_at_ms=NULL,
1019
+ request_json=excluded.request_json,
1020
+ updated_at=excluded.updated_at`,
1021
+ )
1022
+ .run(
1023
+ approvalId,
1024
+ String(request.sessionKey ?? "").trim() || null,
1025
+ String(request.command ?? ""),
1026
+ String(request.cwd ?? "").trim() || null,
1027
+ String(request.host ?? "").trim() || null,
1028
+ String(request.security ?? "").trim() || null,
1029
+ String(request.ask ?? "").trim() || null,
1030
+ Number(payload?.createdAtMs ?? nowMs()),
1031
+ Number(payload?.expiresAtMs ?? nowMs()),
1032
+ toJson(payload),
1033
+ now,
1034
+ );
1035
+ }
1036
+
1037
+ async recordApprovalResolved(payload) {
1038
+ const approvalId = String(payload?.id ?? "").trim();
1039
+ if (!approvalId) {
1040
+ return;
1041
+ }
1042
+ const decision = normalizeApprovalDecision(payload?.decision);
1043
+ const now = new Date().toISOString();
1044
+ const resolvedAtMs =
1045
+ typeof payload?.ts === "number" && Number.isFinite(payload.ts) ? payload.ts : nowMs();
1046
+ this._db()
1047
+ .prepare(
1048
+ `UPDATE approvals
1049
+ SET status=?,
1050
+ decision=?,
1051
+ resolved_at_ms=?,
1052
+ updated_at=?
1053
+ WHERE approval_id=?`,
1054
+ )
1055
+ .run(
1056
+ normalizeApprovalStatus("resolved", decision),
1057
+ decision,
1058
+ resolvedAtMs,
1059
+ now,
1060
+ approvalId,
1061
+ );
1062
+ }
1063
+
1064
+ async listApprovals({ sessionKey, status = "pending", limit = 100 } = {}) {
1065
+ const normalizedSessionKey = String(sessionKey ?? "").trim();
1066
+ const normalizedStatus = String(status ?? "").trim().toLowerCase();
1067
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 100, 500));
1068
+ const rows =
1069
+ normalizedSessionKey
1070
+ ? this._db()
1071
+ .prepare(
1072
+ `SELECT *
1073
+ FROM approvals
1074
+ WHERE session_key=? AND status=?
1075
+ ORDER BY created_at_ms DESC
1076
+ LIMIT ?`,
1077
+ )
1078
+ .all(normalizedSessionKey, normalizedStatus, normalizedLimit)
1079
+ : this._db()
1080
+ .prepare(
1081
+ `SELECT *
1082
+ FROM approvals
1083
+ WHERE status=?
1084
+ ORDER BY created_at_ms DESC
1085
+ LIMIT ?`,
1086
+ )
1087
+ .all(normalizedStatus, normalizedLimit);
1088
+
1089
+ return rows
1090
+ .map((row) => {
1091
+ const payload = fromJson(row.request_json, {});
1092
+ const request =
1093
+ payload?.request && typeof payload.request === "object" ? payload.request : {};
1094
+ return {
1095
+ id: row.approval_id,
1096
+ request: {
1097
+ ...request,
1098
+ command: row.command,
1099
+ cwd: row.cwd,
1100
+ host: row.host,
1101
+ security: row.security,
1102
+ ask: row.ask,
1103
+ sessionKey: row.session_key,
1104
+ },
1105
+ createdAtMs: row.created_at_ms,
1106
+ expiresAtMs: row.expires_at_ms,
1107
+ status: row.status,
1108
+ decision: row.decision,
1109
+ resolvedAtMs: row.resolved_at_ms,
1110
+ };
1111
+ })
1112
+ .reverse();
1113
+ }
1114
+
1115
+ async upsertArtifact({ sessionKey, runId, document }) {
1116
+ const artifactId = String(document?.artifactId ?? document?.artifact_id ?? "").trim();
1117
+ if (!artifactId) {
1118
+ throw new Error("artifactId is required");
1119
+ }
1120
+ const kind = String(document?.kind ?? "").trim();
1121
+ const title = String(document?.title ?? "").trim();
1122
+ if (!kind || !title) {
1123
+ throw new Error("artifact kind and title are required");
1124
+ }
1125
+ const timestamp = nowMs();
1126
+ this._db()
1127
+ .prepare(
1128
+ `INSERT INTO artifacts(
1129
+ artifact_id,
1130
+ session_key,
1131
+ run_id,
1132
+ kind,
1133
+ title,
1134
+ summary,
1135
+ document_json,
1136
+ created_at_ms,
1137
+ updated_at_ms
1138
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
1139
+ ON CONFLICT(artifact_id) DO UPDATE SET
1140
+ session_key=excluded.session_key,
1141
+ run_id=excluded.run_id,
1142
+ kind=excluded.kind,
1143
+ title=excluded.title,
1144
+ summary=excluded.summary,
1145
+ document_json=excluded.document_json,
1146
+ updated_at_ms=excluded.updated_at_ms`,
1147
+ )
1148
+ .run(
1149
+ artifactId,
1150
+ String(sessionKey ?? "").trim() || null,
1151
+ String(runId ?? "").trim() || null,
1152
+ kind,
1153
+ title,
1154
+ String(document?.summary ?? "").trim() || null,
1155
+ toJson(document),
1156
+ timestamp,
1157
+ timestamp,
1158
+ );
1159
+ return this.readArtifactById(artifactId);
1160
+ }
1161
+
1162
+ async readArtifactById(artifactId) {
1163
+ const normalized = String(artifactId ?? "").trim();
1164
+ if (!normalized) return null;
1165
+ const row = this._db()
1166
+ .prepare(`SELECT * FROM artifacts WHERE artifact_id=? LIMIT 1`)
1167
+ .get(normalized);
1168
+ if (!row) return null;
1169
+ return {
1170
+ artifactId: row.artifact_id,
1171
+ sessionKey: row.session_key,
1172
+ runId: row.run_id,
1173
+ kind: row.kind,
1174
+ title: row.title,
1175
+ summary: row.summary,
1176
+ document: fromJson(row.document_json, null),
1177
+ createdAtMs: row.created_at_ms,
1178
+ updatedAtMs: row.updated_at_ms,
1179
+ };
1180
+ }
1181
+
1182
+ async resolveArtifactBlobPath(blobId) {
1183
+ const normalized = String(blobId ?? "").trim();
1184
+ if (!normalized) return null;
1185
+ const row = this._db()
1186
+ .prepare(`SELECT storage_path FROM artifact_blobs WHERE blob_id=? LIMIT 1`)
1187
+ .get(normalized);
1188
+ const storagePath = String(row?.storage_path ?? "").trim();
1189
+ return storagePath || null;
1190
+ }
1191
+
1192
+ async registerClientSession({ clientId, sessionKey, platform, appVersion, capabilities }) {
1193
+ const normalizedClientId = String(clientId ?? "").trim();
1194
+ if (!normalizedClientId) {
1195
+ throw new Error("clientId is required");
1196
+ }
1197
+ const timestamp = nowMs();
1198
+ this._db()
1199
+ .prepare(
1200
+ `INSERT INTO client_sessions(
1201
+ client_id,
1202
+ session_key,
1203
+ platform,
1204
+ app_version,
1205
+ capabilities_json,
1206
+ last_seen_at_ms,
1207
+ updated_at_ms
1208
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)
1209
+ ON CONFLICT(client_id) DO UPDATE SET
1210
+ session_key=excluded.session_key,
1211
+ platform=excluded.platform,
1212
+ app_version=excluded.app_version,
1213
+ capabilities_json=excluded.capabilities_json,
1214
+ last_seen_at_ms=excluded.last_seen_at_ms,
1215
+ updated_at_ms=excluded.updated_at_ms`,
1216
+ )
1217
+ .run(
1218
+ normalizedClientId,
1219
+ String(sessionKey ?? "").trim() || null,
1220
+ String(platform ?? "").trim() || null,
1221
+ String(appVersion ?? "").trim() || null,
1222
+ toJson(capabilities ?? {}),
1223
+ timestamp,
1224
+ timestamp,
1225
+ );
1226
+ return {
1227
+ clientId: normalizedClientId,
1228
+ sessionKey: String(sessionKey ?? "").trim() || null,
1229
+ lastSeenAtMs: timestamp,
1230
+ };
1231
+ }
1232
+
1233
+ async resolveClientForOpenTarget(target) {
1234
+ const normalizedTarget = String(target ?? "").trim().toLowerCase();
1235
+ if (!normalizedTarget) return null;
1236
+ const freshnessCutoff = nowMs() - 30_000;
1237
+ const rows = this._db()
1238
+ .prepare(
1239
+ `SELECT client_id, session_key, platform, app_version, capabilities_json, last_seen_at_ms
1240
+ FROM client_sessions
1241
+ WHERE last_seen_at_ms >= ?
1242
+ ORDER BY last_seen_at_ms DESC`,
1243
+ )
1244
+ .all(freshnessCutoff);
1245
+ for (const row of rows) {
1246
+ const capabilities = fromJson(row.capabilities_json, {}) ?? {};
1247
+ const openCapabilities =
1248
+ capabilities?.mobcode_open && typeof capabilities.mobcode_open === "object"
1249
+ ? capabilities.mobcode_open
1250
+ : {};
1251
+ const targets = Array.isArray(openCapabilities.targets) ? openCapabilities.targets : [];
1252
+ if (targets.map((value) => String(value).trim().toLowerCase()).includes(normalizedTarget)) {
1253
+ return {
1254
+ clientId: row.client_id,
1255
+ sessionKey: row.session_key,
1256
+ platform: row.platform,
1257
+ appVersion: row.app_version,
1258
+ };
1259
+ }
1260
+ }
1261
+ return null;
1262
+ }
1263
+
1264
+ async enqueueClientAction({ clientId, type, payload }) {
1265
+ const normalizedClientId = String(clientId ?? "").trim();
1266
+ const normalizedType = String(type ?? "").trim();
1267
+ if (!normalizedClientId || !normalizedType) {
1268
+ throw new Error("clientId and type are required");
1269
+ }
1270
+ const actionId = crypto.randomUUID();
1271
+ const createdAtMs = nowMs();
1272
+ this._db()
1273
+ .prepare(
1274
+ `INSERT INTO client_actions(
1275
+ action_id,
1276
+ client_id,
1277
+ type,
1278
+ payload_json,
1279
+ status,
1280
+ created_at_ms
1281
+ ) VALUES(?, ?, ?, ?, 'pending', ?)`,
1282
+ )
1283
+ .run(actionId, normalizedClientId, normalizedType, toJson(payload ?? {}), createdAtMs);
1284
+ const action = {
1285
+ id: actionId,
1286
+ clientId: normalizedClientId,
1287
+ type: normalizedType,
1288
+ payload: cloneJson(payload ?? {}),
1289
+ status: "pending",
1290
+ createdAtMs,
1291
+ };
1292
+ const waiter = this.clientActionWaiters.get(normalizedClientId);
1293
+ if (waiter) {
1294
+ this.clientActionWaiters.delete(normalizedClientId);
1295
+ clearTimeout(waiter.timer);
1296
+ waiter.resolve(action);
1297
+ }
1298
+ return action;
1299
+ }
1300
+
1301
+ async pollClientActions(clientId, { limit = 20 } = {}) {
1302
+ const normalizedClientId = String(clientId ?? "").trim();
1303
+ if (!normalizedClientId) {
1304
+ return [];
1305
+ }
1306
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 20, 100));
1307
+ const rows = this._db()
1308
+ .prepare(
1309
+ `SELECT action_id, type, payload_json, status, created_at_ms
1310
+ FROM client_actions
1311
+ WHERE client_id=? AND status='pending'
1312
+ ORDER BY created_at_ms ASC
1313
+ LIMIT ?`,
1314
+ )
1315
+ .all(normalizedClientId, normalizedLimit);
1316
+ return rows.map((row) => ({
1317
+ id: row.action_id,
1318
+ type: row.type,
1319
+ payload: fromJson(row.payload_json, {}) ?? {},
1320
+ status: row.status,
1321
+ createdAtMs: row.created_at_ms,
1322
+ }));
1323
+ }
1324
+
1325
+ async waitForClientAction(clientId, { timeoutMs = 60_000 } = {}) {
1326
+ const pending = await this.pollClientActions(clientId, { limit: 1 });
1327
+ if (pending.length > 0) {
1328
+ return pending[0];
1329
+ }
1330
+ const normalizedClientId = String(clientId ?? "").trim();
1331
+ if (!normalizedClientId) {
1332
+ return null;
1333
+ }
1334
+ return await new Promise((resolve) => {
1335
+ const timer = setTimeout(() => {
1336
+ const active = this.clientActionWaiters.get(normalizedClientId);
1337
+ if (active?.timer === timer) {
1338
+ this.clientActionWaiters.delete(normalizedClientId);
1339
+ }
1340
+ resolve(null);
1341
+ }, Math.max(1_000, Number(timeoutMs) || 60_000));
1342
+ this.clientActionWaiters.set(normalizedClientId, { resolve, timer });
1343
+ });
1344
+ }
1345
+
1346
+ async ackClientAction({ clientId, actionId, status, errorText }) {
1347
+ const normalizedClientId = String(clientId ?? "").trim();
1348
+ const normalizedActionId = String(actionId ?? "").trim();
1349
+ const normalizedStatus = String(status ?? "").trim().toLowerCase();
1350
+ if (!normalizedClientId || !normalizedActionId || !normalizedStatus) {
1351
+ throw new Error("clientId, actionId, and status are required");
1352
+ }
1353
+ const ackedAtMs = nowMs();
1354
+ this._db()
1355
+ .prepare(
1356
+ `UPDATE client_actions
1357
+ SET status=?,
1358
+ acked_at_ms=?,
1359
+ error_text=?
1360
+ WHERE action_id=? AND client_id=?`,
1361
+ )
1362
+ .run(
1363
+ normalizedStatus,
1364
+ ackedAtMs,
1365
+ String(errorText ?? "").trim() || null,
1366
+ normalizedActionId,
1367
+ normalizedClientId,
1368
+ );
1369
+ const result = {
1370
+ ok: true,
1371
+ actionId: normalizedActionId,
1372
+ status: normalizedStatus,
1373
+ ackedAtMs,
1374
+ };
1375
+ const resolutionWaiter = this.clientActionResolutionWaiters.get(normalizedActionId);
1376
+ if (resolutionWaiter) {
1377
+ this.clientActionResolutionWaiters.delete(normalizedActionId);
1378
+ clearTimeout(resolutionWaiter.timer);
1379
+ resolutionWaiter.resolve({
1380
+ ...result,
1381
+ errorText: String(errorText ?? "").trim() || null,
1382
+ });
1383
+ }
1384
+ return result;
1385
+ }
1386
+
1387
+ async waitForClientActionResolution(actionId, { timeoutMs = 20_000 } = {}) {
1388
+ const normalizedActionId = String(actionId ?? "").trim();
1389
+ if (!normalizedActionId) {
1390
+ return {
1391
+ ok: false,
1392
+ actionId: normalizedActionId,
1393
+ status: "error",
1394
+ errorText: "actionId required",
1395
+ };
1396
+ }
1397
+ const current = this._db()
1398
+ .prepare(
1399
+ `SELECT status, acked_at_ms, error_text
1400
+ FROM client_actions
1401
+ WHERE action_id=? LIMIT 1`,
1402
+ )
1403
+ .get(normalizedActionId);
1404
+ const currentStatus = String(current?.status ?? "").trim().toLowerCase();
1405
+ if (currentStatus && currentStatus !== "pending") {
1406
+ return {
1407
+ ok: currentStatus === "done",
1408
+ actionId: normalizedActionId,
1409
+ status: currentStatus,
1410
+ ackedAtMs: current?.acked_at_ms ?? null,
1411
+ errorText: String(current?.error_text ?? "").trim() || null,
1412
+ };
1413
+ }
1414
+ return await new Promise((resolve) => {
1415
+ const timer = setTimeout(() => {
1416
+ const active = this.clientActionResolutionWaiters.get(normalizedActionId);
1417
+ if (active?.timer === timer) {
1418
+ this.clientActionResolutionWaiters.delete(normalizedActionId);
1419
+ }
1420
+ resolve({
1421
+ ok: false,
1422
+ actionId: normalizedActionId,
1423
+ status: "timeout",
1424
+ errorText: "Mobile client did not respond in time.",
1425
+ });
1426
+ }, Math.max(1_000, Number(timeoutMs) || 20_000));
1427
+ this.clientActionResolutionWaiters.set(normalizedActionId, { resolve, timer });
1428
+ });
1429
+ }
1430
+ }