@dinoxx/dinox-cli 2.3.0 → 2.4.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.
Files changed (47) hide show
  1. package/README.md +17 -3
  2. package/dist/cli.js +2 -0
  3. package/dist/commands/gateway/index.d.ts +4 -0
  4. package/dist/commands/gateway/index.js +300 -0
  5. package/dist/commands/schema/renderSkills.js +4 -0
  6. package/dist/commands/schema/spec.js +2 -0
  7. package/dist/config/paths.d.ts +1 -0
  8. package/dist/config/paths.js +1 -1
  9. package/dist/daemon/server.d.ts +1 -0
  10. package/dist/daemon/server.js +31 -0
  11. package/dist/gateway/adapters/acp.d.ts +42 -0
  12. package/dist/gateway/adapters/acp.js +285 -0
  13. package/dist/gateway/adapters/echo.d.ts +12 -0
  14. package/dist/gateway/adapters/echo.js +51 -0
  15. package/dist/gateway/adapters/index.d.ts +9 -0
  16. package/dist/gateway/adapters/index.js +30 -0
  17. package/dist/gateway/assistantProjection.d.ts +32 -0
  18. package/dist/gateway/assistantProjection.js +193 -0
  19. package/dist/gateway/config.d.ts +50 -0
  20. package/dist/gateway/config.js +253 -0
  21. package/dist/gateway/detect.d.ts +40 -0
  22. package/dist/gateway/detect.js +145 -0
  23. package/dist/gateway/deviceHost.d.ts +82 -0
  24. package/dist/gateway/deviceHost.js +530 -0
  25. package/dist/gateway/mcpServer.d.ts +4 -0
  26. package/dist/gateway/mcpServer.js +118 -0
  27. package/dist/gateway/protocol.d.ts +29 -0
  28. package/dist/gateway/protocol.js +127 -0
  29. package/dist/gateway/runExecutor.d.ts +67 -0
  30. package/dist/gateway/runExecutor.js +316 -0
  31. package/dist/gateway/service.d.ts +26 -0
  32. package/dist/gateway/service.js +82 -0
  33. package/dist/gateway/streamPublisher.d.ts +33 -0
  34. package/dist/gateway/streamPublisher.js +93 -0
  35. package/dist/gateway/threadStore.d.ts +157 -0
  36. package/dist/gateway/threadStore.js +430 -0
  37. package/dist/gateway/types.d.ts +117 -0
  38. package/dist/gateway/types.js +9 -0
  39. package/dist/vendor/electron/domain/properties.js +12 -1
  40. package/dist/vendor/electron/domain/saved-views.js +4 -1
  41. package/dist/vendor/electron/shared/external-types.d.ts +18 -0
  42. package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
  43. package/dist/vendor/electron/shared/note-properties.js +45 -0
  44. package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
  45. package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
  46. package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
  47. package/package.json +5 -2
@@ -0,0 +1,93 @@
1
+ import { REMOTE_DEVICE_MAX_EVENT_BYTES, REMOTE_DEVICE_PROTOCOL_VERSION, } from '../vendor/electron/shared/remote-device-agent.js';
2
+ import { TERMINAL_RUN_STATUSES } from './threadStore.js';
3
+ const COALESCE_MS = 100;
4
+ const MAX_APPROVALS = 32;
5
+ function byteLength(value) {
6
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
7
+ }
8
+ /**
9
+ * Keeps an event under the NATS payload budget: drop parts (the snapshot is
10
+ * self-correcting and `run.status` recovers the rest), then drop extras.
11
+ */
12
+ export function boundRunEvent(event) {
13
+ if (byteLength(event) <= REMOTE_DEVICE_MAX_EVENT_BYTES)
14
+ return event;
15
+ const metadata = event.assistant.message.metadata ?? {};
16
+ const stripped = {
17
+ ...event,
18
+ assistant: {
19
+ message: {
20
+ ...event.assistant.message,
21
+ parts: [],
22
+ metadata: { status: metadata.status, runId: metadata.runId },
23
+ },
24
+ truncated: true,
25
+ },
26
+ pendingApprovals: event.pendingApprovals?.slice(0, MAX_APPROVALS),
27
+ };
28
+ if (byteLength(stripped) <= REMOTE_DEVICE_MAX_EVENT_BYTES)
29
+ return stripped;
30
+ const minimal = { ...stripped, pendingApprovals: [], usage: null };
31
+ if (byteLength(minimal) <= REMOTE_DEVICE_MAX_EVENT_BYTES)
32
+ return minimal;
33
+ throw new Error('run.event exceeds the remote-device payload limit');
34
+ }
35
+ /**
36
+ * One per run. Streaming snapshots are coalesced to the latest per 100ms;
37
+ * approval and terminal states go out immediately (and supersede a pending one).
38
+ */
39
+ export class RunStreamPublisher {
40
+ ids;
41
+ publish;
42
+ sequence = 0;
43
+ pending = null;
44
+ timer = null;
45
+ disposed = false;
46
+ constructor(ids, publish) {
47
+ this.ids = ids;
48
+ this.publish = publish;
49
+ }
50
+ push(snapshot) {
51
+ if (this.disposed)
52
+ return;
53
+ if (TERMINAL_RUN_STATUSES.has(snapshot.status) || snapshot.status === 'waiting_approval') {
54
+ this.clearTimer();
55
+ this.pending = null;
56
+ this.emit(snapshot);
57
+ return;
58
+ }
59
+ this.pending = snapshot;
60
+ this.timer ??= setTimeout(() => {
61
+ this.timer = null;
62
+ const latest = this.pending;
63
+ this.pending = null;
64
+ if (latest)
65
+ this.emit(latest);
66
+ }, COALESCE_MS);
67
+ }
68
+ dispose() {
69
+ this.disposed = true;
70
+ this.clearTimer();
71
+ this.pending = null;
72
+ }
73
+ clearTimer() {
74
+ if (this.timer)
75
+ clearTimeout(this.timer);
76
+ this.timer = null;
77
+ }
78
+ emit(snapshot) {
79
+ this.sequence += 1;
80
+ this.publish(boundRunEvent({
81
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
82
+ type: 'run.event',
83
+ runId: this.ids.runId,
84
+ threadId: this.ids.threadId,
85
+ sequence: this.sequence,
86
+ emittedAt: new Date().toISOString(),
87
+ revision: snapshot.revision,
88
+ status: snapshot.status,
89
+ assistant: { message: snapshot.assistant },
90
+ pendingApprovals: snapshot.pendingApprovals,
91
+ }));
92
+ }
93
+ }
@@ -0,0 +1,157 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import type { ChatUIMessage, ThreadRunCreateThreadRequest, ThreadRunEventStatus } from '../vendor/electron/shared/external-types.js';
3
+ /**
4
+ * Persistence for gateway runs, written exactly like Dinox desktop's remote host
5
+ * (dinox-electron ThreadRunQueueService / ThreadRunCoordinatorHelpers) so every
6
+ * client reads identical rows:
7
+ * - the user message is written when the run is accepted;
8
+ * - live progress stays in the local-only `local_thread_run` snapshot;
9
+ * - the assistant message lands in `c_chat_message` once, when the run ends.
10
+ */
11
+ type Tx = Pick<AbstractPowerSyncDatabase, 'execute' | 'getOptional' | 'getAll'>;
12
+ export declare const TERMINAL_RUN_STATUSES: ReadonlySet<ThreadRunEventStatus>;
13
+ export type StoredRun = {
14
+ id: string;
15
+ threadId: string;
16
+ status: ThreadRunEventStatus;
17
+ requestId: string | null;
18
+ assistant: ChatUIMessage | null;
19
+ updatedAt: string | null;
20
+ };
21
+ /** Mirrors desktop `withRunMetadata`: the UI reads `metadata.status` for streaming state. */
22
+ export declare function withRunMetadata(message: ChatUIMessage, runId: string, status: ThreadRunEventStatus): ChatUIMessage;
23
+ /**
24
+ * Synced queue marker on a user message (`metadata.queue`), shared by every
25
+ * client: the run that answers it has not started yet. It lives on the synced
26
+ * message because run rows (`local_thread_run`) are local to one process, so
27
+ * other devices could not otherwise show the queue. Removed when the run starts.
28
+ */
29
+ export type MessageQueueMarker = {
30
+ state: 'queued' | 'withdrawn';
31
+ runId: string;
32
+ executor: 'gateway';
33
+ queuedAt: string;
34
+ };
35
+ /** The answering run merged this turn into a later one; it never gets its own reply. */
36
+ export declare function withMergedInto(message: ChatUIMessage, mergedIntoRunId: string): ChatUIMessage;
37
+ /** Next `order_index` after `parentId` (midpoint before its next sibling) or at the end. */
38
+ export declare function resolveNextOrderIndex(tx: Tx, input: {
39
+ threadId: string;
40
+ parentId: string | null;
41
+ }): Promise<number>;
42
+ export declare function threadExists(db: Tx, threadId: string, userId: string): Promise<boolean>;
43
+ export type EnqueueRunInput = {
44
+ runId: string;
45
+ threadId: string;
46
+ userId: string;
47
+ requestId: string;
48
+ userMessage: ChatUIMessage;
49
+ assistantMessage: ChatUIMessage;
50
+ parentMessageId: string | null;
51
+ createThread: ThreadRunCreateThreadRequest | null;
52
+ requestPayload: Record<string, unknown>;
53
+ /** Waits behind another turn of the same thread: marks the user message as queued. */
54
+ queued: boolean;
55
+ /** FIFO order within the thread. */
56
+ queueIndex: number;
57
+ /**
58
+ * Parent of a queued turn: the reply still being written, or the turn queued
59
+ * just before it. Absent for a turn that runs now, which follows the thread's
60
+ * last message.
61
+ */
62
+ queuedAfterMessageId?: string;
63
+ };
64
+ /** Persists the accepted turn atomically: thread (if new), user message, queued run. */
65
+ export declare function enqueueRun(db: AbstractPowerSyncDatabase, input: EnqueueRunInput): Promise<{
66
+ threadCreated: boolean;
67
+ }>;
68
+ /** Live progress for `run.status` recovery; never touches synced tables. */
69
+ export declare function updateRunSnapshot(db: Tx, input: {
70
+ runId: string;
71
+ status: ThreadRunEventStatus;
72
+ assistant: ChatUIMessage;
73
+ }): Promise<void>;
74
+ /** Ends the run and writes the final assistant message into the synced transcript. */
75
+ export declare function commitRunTerminal(db: AbstractPowerSyncDatabase, input: {
76
+ runId: string;
77
+ threadId: string;
78
+ userId: string;
79
+ userMessageId: string;
80
+ status: ThreadRunEventStatus;
81
+ assistant: ChatUIMessage;
82
+ errorText: string | null;
83
+ }): Promise<void>;
84
+ export declare function findRun(db: Tx, input: {
85
+ runId: string;
86
+ userId: string;
87
+ }): Promise<StoredRun | null>;
88
+ /** The queued turns start now: their messages stop showing as queued. */
89
+ export declare function startQueuedRuns(db: AbstractPowerSyncDatabase, userMessageIds: string[]): Promise<void>;
90
+ /** Ends a queued turn that was merged into a later one: no reply of its own is written. */
91
+ export declare function finishMergedRun(db: Tx, input: {
92
+ runId: string;
93
+ mergedIntoRunId: string;
94
+ assistant: ChatUIMessage;
95
+ }): Promise<ChatUIMessage>;
96
+ /**
97
+ * The user took a queued turn back before it ran: the run is cancelled and its
98
+ * message removed from the transcript (marked `withdrawn` so other devices can
99
+ * tell a withdrawal from a deletion).
100
+ */
101
+ export declare function withdrawQueuedRun(db: AbstractPowerSyncDatabase, input: {
102
+ runId: string;
103
+ userMessageId: string;
104
+ assistant: ChatUIMessage;
105
+ }): Promise<ChatUIMessage>;
106
+ /** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
107
+ export declare function interruptOrphanedRuns(db: Tx, userId: string): Promise<number>;
108
+ /** Agent session bound to a thread, scoped to the thread's current topic. */
109
+ export type GatewaySessionBinding = {
110
+ sessionId: string;
111
+ topicId: string | null;
112
+ gatewayId: string;
113
+ };
114
+ export declare function readSessionBinding(db: Tx, threadId: string): Promise<GatewaySessionBinding | null>;
115
+ export declare function writeSessionBinding(db: Tx, threadId: string, binding: GatewaySessionBinding): Promise<void>;
116
+ /**
117
+ * The thread's current topic: the latest "new topic" marker the app wrote
118
+ * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
119
+ * starts a fresh agent session.
120
+ */
121
+ export declare function readCurrentTopicId(db: Tx, threadId: string): Promise<string | null>;
122
+ /** File parts resolve only to resources this user owns with an https storage URL. */
123
+ export declare function resolveTrustedResource(db: Tx, input: {
124
+ resourceId: string;
125
+ userId: string;
126
+ }): Promise<{
127
+ url: string;
128
+ name: string;
129
+ mediaType: string | null;
130
+ } | null>;
131
+ /** Registers the gateway as a `c_robot` the app can list and bind agents to (no secrets). */
132
+ export type RobotRegistration = {
133
+ id: string;
134
+ userId: string;
135
+ name: string;
136
+ deviceId: string;
137
+ agentType: string;
138
+ cwd: string | null;
139
+ /** This machine's hostname, shown with the robot. */
140
+ deviceName: string;
141
+ /** Marks the robot as hosted by this installation, for reconciliation. */
142
+ machineId: string;
143
+ agentId?: string;
144
+ };
145
+ export declare function upsertRobot(db: Tx, input: RobotRegistration): Promise<void>;
146
+ /**
147
+ * Soft-deletes this machine's gateway robots that are no longer configured
148
+ * (removed, or replaced before ids were deterministic), so the app never
149
+ * offers an agent that nothing hosts. Robots from other machines are untouched.
150
+ */
151
+ export declare function retireRemovedRobots(db: Tx, input: {
152
+ userId: string;
153
+ machineId: string;
154
+ keepIds: string[];
155
+ }): Promise<string[]>;
156
+ export declare function softDeleteRobot(db: Tx, robotId: string): Promise<void>;
157
+ export {};
@@ -0,0 +1,430 @@
1
+ import { withPowerSyncWriteTransaction } from '../powersync/runtime.js';
2
+ const THREAD_CHAT_TYPE = 'thread';
3
+ const MESSAGE_ENTRY_TYPE = 'message';
4
+ const LEGACY_MESSAGE_ENTRY_TYPE = 'thread';
5
+ const CONTEXT_RESET_ENTRY_TYPE = 'internal';
6
+ const CHAT_MESSAGE_VERSION = 'v1';
7
+ const ORDER_INDEX_STEP = 1024;
8
+ const RUN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
9
+ const MESSAGE_ENTRY_FILTER = '(entry_type IS NULL OR entry_type = \'\' OR entry_type = ? OR entry_type = ?)';
10
+ export const TERMINAL_RUN_STATUSES = new Set([
11
+ 'succeeded',
12
+ 'failed',
13
+ 'cancelled',
14
+ 'interrupted',
15
+ 'expired',
16
+ ]);
17
+ function safeJson(value) {
18
+ return JSON.stringify(value ?? null);
19
+ }
20
+ function parseJson(value) {
21
+ if (typeof value !== 'string' || !value)
22
+ return null;
23
+ try {
24
+ return JSON.parse(value);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function toOrderIndex(value) {
31
+ const parsed = typeof value === 'string' ? Number(value) : value;
32
+ return typeof parsed === 'number' && Number.isSafeInteger(parsed) ? parsed : null;
33
+ }
34
+ /** Mirrors desktop `withRunMetadata`: the UI reads `metadata.status` for streaming state. */
35
+ export function withRunMetadata(message, runId, status) {
36
+ const uiStatus = status === 'succeeded' || status === 'waiting_approval'
37
+ ? 'done'
38
+ : TERMINAL_RUN_STATUSES.has(status)
39
+ ? 'error'
40
+ : 'streaming';
41
+ return { ...message, metadata: { ...(message.metadata ?? {}), runId, status: uiStatus } };
42
+ }
43
+ function withQueueMarker(message, marker) {
44
+ const { queue: _previous, ...metadata } = (message.metadata ?? {});
45
+ return { ...message, metadata: marker ? { ...metadata, queue: marker } : metadata };
46
+ }
47
+ /** The answering run merged this turn into a later one; it never gets its own reply. */
48
+ export function withMergedInto(message, mergedIntoRunId) {
49
+ return { ...message, metadata: { ...(message.metadata ?? {}), mergedIntoRunId } };
50
+ }
51
+ function messageCreatedAt(message, fallback) {
52
+ const raw = message.metadata?.createTime;
53
+ if (typeof raw === 'number' && Number.isFinite(raw))
54
+ return new Date(raw).toISOString();
55
+ if (typeof raw === 'string' && raw.trim())
56
+ return raw;
57
+ return fallback;
58
+ }
59
+ /** Next `order_index` after `parentId` (midpoint before its next sibling) or at the end. */
60
+ export async function resolveNextOrderIndex(tx, input) {
61
+ const row = await tx.getOptional(`SELECT MAX(order_index) AS max_order_index, COUNT(*) AS message_count,
62
+ (SELECT order_index FROM c_chat_message
63
+ WHERE id = ? AND type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
64
+ LIMIT 1) AS parent_order_index
65
+ FROM c_chat_message
66
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}`, [
67
+ input.parentId ?? '',
68
+ THREAD_CHAT_TYPE,
69
+ input.threadId,
70
+ MESSAGE_ENTRY_TYPE,
71
+ LEGACY_MESSAGE_ENTRY_TYPE,
72
+ THREAD_CHAT_TYPE,
73
+ input.threadId,
74
+ MESSAGE_ENTRY_TYPE,
75
+ LEGACY_MESSAGE_ENTRY_TYPE,
76
+ ]);
77
+ const maxOrderIndex = toOrderIndex(row?.max_order_index);
78
+ const parentOrderIndex = toOrderIndex(row?.parent_order_index);
79
+ const countBased = Math.max(0, Number(row?.message_count ?? 0)) * ORDER_INDEX_STEP;
80
+ if (parentOrderIndex != null) {
81
+ const next = await tx.getOptional(`SELECT MIN(order_index) AS next_order_index FROM c_chat_message
82
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND order_index > ? AND ${MESSAGE_ENTRY_FILTER}`, [THREAD_CHAT_TYPE, input.threadId, parentOrderIndex, MESSAGE_ENTRY_TYPE, LEGACY_MESSAGE_ENTRY_TYPE]);
83
+ const nextOrderIndex = toOrderIndex(next?.next_order_index);
84
+ if (nextOrderIndex != null && nextOrderIndex - parentOrderIndex > 1) {
85
+ return parentOrderIndex + Math.floor((nextOrderIndex - parentOrderIndex) / 2);
86
+ }
87
+ }
88
+ return Math.max(maxOrderIndex ?? 0, parentOrderIndex ?? 0, countBased) + ORDER_INDEX_STEP;
89
+ }
90
+ /**
91
+ * The thread's last message, by transcript order. A new turn is appended after
92
+ * it: what the client believed was last can be stale or out of order (another
93
+ * device, a lagging sync, a display glitch), and trusting it would splice the
94
+ * turn into the middle of the conversation.
95
+ */
96
+ async function resolveThreadTail(tx, threadId) {
97
+ const row = await tx.getOptional(`SELECT id FROM c_chat_message
98
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
99
+ ORDER BY order_index DESC, created_at DESC, id DESC LIMIT 1`, [THREAD_CHAT_TYPE, threadId, MESSAGE_ENTRY_TYPE, LEGACY_MESSAGE_ENTRY_TYPE]);
100
+ return row?.id ?? null;
101
+ }
102
+ export async function threadExists(db, threadId, userId) {
103
+ const row = await db.getOptional('SELECT id FROM c_thread WHERE id = ? AND user_id = ? AND is_del = 0 LIMIT 1', [threadId, userId]);
104
+ return Boolean(row?.id);
105
+ }
106
+ /** Creates (or revives) the thread a remote run targets. Returns true when a row was written. */
107
+ async function ensureThread(tx, input, now) {
108
+ const existing = await tx.getOptional('SELECT id, is_del FROM c_thread WHERE id = ? LIMIT 1', [input.id]);
109
+ if (existing?.id && Number(existing.is_del) !== 1)
110
+ return false;
111
+ const name = (input.name ?? '').trim().slice(0, 120) || 'Untitled';
112
+ const values = [
113
+ name,
114
+ input.kind || 'chat',
115
+ input.userId,
116
+ input.projectId ?? null,
117
+ now,
118
+ now,
119
+ safeJson(input.metaData ?? {}),
120
+ input.cmdId ?? null,
121
+ safeJson(input.config ?? {}),
122
+ ];
123
+ if (existing?.id) {
124
+ await tx.execute(`UPDATE c_thread SET name = ?, kind = ?, user_id = ?, project_id = ?, last_opened_at = ?, updated_at = ?,
125
+ meta_data = ?, cmd_id = ?, config = ?, filter_condition = '{}', snapshot = '{}', memory = '{}',
126
+ is_del = 0, robot_session_id = NULL, compact_context = NULL
127
+ WHERE id = ?`, [...values, input.id]);
128
+ return true;
129
+ }
130
+ await tx.execute(`INSERT INTO c_thread (id, name, kind, user_id, project_id, last_opened_at, updated_at, meta_data, cmd_id, config,
131
+ created_at, messages, pinned_at, filter_condition, snapshot, memory, is_del, robot_session_id, compact_context)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', NULL, '{}', '{}', '{}', 0, NULL, NULL)`, [input.id, ...values, now]);
133
+ return true;
134
+ }
135
+ async function writeChatMessage(tx, input) {
136
+ const existing = await tx.getOptional('SELECT id, order_index FROM c_chat_message WHERE id = ? LIMIT 1', [input.message.id]);
137
+ const orderIndex = toOrderIndex(existing?.order_index) ??
138
+ (await resolveNextOrderIndex(tx, { threadId: input.threadId, parentId: input.parentId }));
139
+ if (existing?.id) {
140
+ await tx.execute(`UPDATE c_chat_message SET role = ?, type = ?, entry_type = ?, source_id = ?, parent_id = ?, content = ?,
141
+ updated_at = ?, order_index = ?, version = ?, user_id = ?, deleted_at = NULL
142
+ WHERE id = ?`, [
143
+ input.message.role,
144
+ THREAD_CHAT_TYPE,
145
+ MESSAGE_ENTRY_TYPE,
146
+ input.threadId,
147
+ input.parentId,
148
+ safeJson(input.message),
149
+ input.updatedAt,
150
+ orderIndex,
151
+ CHAT_MESSAGE_VERSION,
152
+ input.userId,
153
+ input.message.id,
154
+ ]);
155
+ return;
156
+ }
157
+ await tx.execute(`INSERT INTO c_chat_message (id, role, type, entry_type, source_id, parent_id, content, created_at, updated_at,
158
+ order_index, meta_data, version, user_id, deleted_at)
159
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL)`, [
160
+ input.message.id,
161
+ input.message.role,
162
+ THREAD_CHAT_TYPE,
163
+ MESSAGE_ENTRY_TYPE,
164
+ input.threadId,
165
+ input.parentId,
166
+ safeJson(input.message),
167
+ input.createdAt,
168
+ input.updatedAt,
169
+ orderIndex,
170
+ CHAT_MESSAGE_VERSION,
171
+ input.userId,
172
+ ]);
173
+ }
174
+ /** Persists the accepted turn atomically: thread (if new), user message, queued run. */
175
+ export async function enqueueRun(db, input) {
176
+ const now = new Date().toISOString();
177
+ return withPowerSyncWriteTransaction(db, async (tx) => {
178
+ const threadCreated = input.createThread
179
+ ? await ensureThread(tx, { ...input.createThread, userId: input.userId }, now)
180
+ : false;
181
+ const createdAt = messageCreatedAt(input.userMessage, now);
182
+ const parentId = input.queuedAfterMessageId ?? (await resolveThreadTail(tx, input.threadId)) ?? input.parentMessageId;
183
+ const userMessage = input.queued
184
+ ? withQueueMarker(input.userMessage, { state: 'queued', runId: input.runId, executor: 'gateway', queuedAt: now })
185
+ : input.userMessage;
186
+ await writeChatMessage(tx, {
187
+ threadId: input.threadId,
188
+ userId: input.userId,
189
+ message: userMessage,
190
+ parentId,
191
+ createdAt,
192
+ updatedAt: createdAt,
193
+ });
194
+ await tx.execute(`INSERT INTO local_thread_run (id, thread_id, user_id, status, trigger, user_message_id, assistant_message_id,
195
+ parent_message_id, queued_at, permission_mode, request_payload_json, assistant_snapshot_json, queue_index,
196
+ created_at, updated_at)
197
+ VALUES (?, ?, ?, 'queued', 'submit-message', ?, ?, ?, ?, 'default', ?, ?, ?, ?, ?)`, [
198
+ input.runId,
199
+ input.threadId,
200
+ input.userId,
201
+ input.userMessage.id,
202
+ input.assistantMessage.id,
203
+ parentId,
204
+ now,
205
+ safeJson({ ...input.requestPayload, metadata: { remoteDevice: { requestId: input.requestId } } }),
206
+ safeJson(withRunMetadata(input.assistantMessage, input.runId, 'queued')),
207
+ input.queueIndex,
208
+ now,
209
+ now,
210
+ ]);
211
+ return { threadCreated };
212
+ });
213
+ }
214
+ /** Live progress for `run.status` recovery; never touches synced tables. */
215
+ export async function updateRunSnapshot(db, input) {
216
+ const now = new Date().toISOString();
217
+ await db.execute(`UPDATE local_thread_run SET status = ?, assistant_snapshot_json = ?, updated_at = ?, last_heartbeat_at = ?,
218
+ started_at = COALESCE(started_at, ?)
219
+ WHERE id = ? AND status NOT IN ('succeeded', 'failed', 'cancelled', 'interrupted', 'expired')`, [input.status, safeJson(withRunMetadata(input.assistant, input.runId, input.status)), now, now, now, input.runId]);
220
+ }
221
+ /** Ends the run and writes the final assistant message into the synced transcript. */
222
+ export async function commitRunTerminal(db, input) {
223
+ const finishedAt = new Date().toISOString();
224
+ const assistant = withRunMetadata(input.assistant, input.runId, input.status);
225
+ await withPowerSyncWriteTransaction(db, async (tx) => {
226
+ await writeChatMessage(tx, {
227
+ threadId: input.threadId,
228
+ userId: input.userId,
229
+ message: assistant,
230
+ parentId: input.userMessageId,
231
+ createdAt: messageCreatedAt(assistant, finishedAt),
232
+ updatedAt: finishedAt,
233
+ });
234
+ await tx.execute(`UPDATE local_thread_run SET status = ?, error_text = ?, finished_at = ?, updated_at = ?, expires_at = NULL,
235
+ cleanup_after_at = ?, assistant_snapshot_json = ?
236
+ WHERE id = ?`, [
237
+ input.status,
238
+ input.errorText,
239
+ finishedAt,
240
+ finishedAt,
241
+ new Date(Date.parse(finishedAt) + RUN_RETENTION_MS).toISOString(),
242
+ safeJson(assistant),
243
+ input.runId,
244
+ ]);
245
+ });
246
+ }
247
+ export async function findRun(db, input) {
248
+ const row = await db.getOptional(`SELECT id, thread_id, status, request_payload_json, assistant_snapshot_json, updated_at
249
+ FROM local_thread_run WHERE id = ? AND user_id = ? LIMIT 1`, [input.runId, input.userId]);
250
+ if (!row)
251
+ return null;
252
+ const payload = parseJson(row.request_payload_json);
253
+ const requestId = payload?.metadata?.remoteDevice?.requestId;
254
+ return {
255
+ id: row.id,
256
+ threadId: row.thread_id,
257
+ status: row.status,
258
+ requestId: typeof requestId === 'string' ? requestId : null,
259
+ assistant: parseJson(row.assistant_snapshot_json),
260
+ updatedAt: row.updated_at,
261
+ };
262
+ }
263
+ /** Rewrites a user message's queue marker in place; a missing message is ignored. */
264
+ async function writeQueueMarker(tx, messageId, marker, extra = {}) {
265
+ const row = await tx.getOptional('SELECT content FROM c_chat_message WHERE id = ? LIMIT 1', [messageId]);
266
+ const message = parseJson(row?.content);
267
+ if (!message)
268
+ return;
269
+ const now = new Date().toISOString();
270
+ await tx.execute(`UPDATE c_chat_message SET content = ?, updated_at = ?${extra.deletedAt ? ', deleted_at = ?' : ''} WHERE id = ?`, extra.deletedAt
271
+ ? [safeJson(withQueueMarker(message, marker)), now, extra.deletedAt, messageId]
272
+ : [safeJson(withQueueMarker(message, marker)), now, messageId]);
273
+ }
274
+ /** The queued turns start now: their messages stop showing as queued. */
275
+ export async function startQueuedRuns(db, userMessageIds) {
276
+ if (userMessageIds.length === 0)
277
+ return;
278
+ await withPowerSyncWriteTransaction(db, async (tx) => {
279
+ for (const messageId of userMessageIds)
280
+ await writeQueueMarker(tx, messageId, null);
281
+ });
282
+ }
283
+ /** Ends a queued turn that was merged into a later one: no reply of its own is written. */
284
+ export async function finishMergedRun(db, input) {
285
+ const now = new Date().toISOString();
286
+ const assistant = withRunMetadata(withMergedInto(input.assistant, input.mergedIntoRunId), input.runId, 'succeeded');
287
+ await db.execute(`UPDATE local_thread_run SET status = 'succeeded', finished_at = ?, updated_at = ?, assistant_snapshot_json = ?,
288
+ cleanup_after_at = ?
289
+ WHERE id = ?`, [now, now, safeJson(assistant), new Date(Date.parse(now) + RUN_RETENTION_MS).toISOString(), input.runId]);
290
+ return assistant;
291
+ }
292
+ /**
293
+ * The user took a queued turn back before it ran: the run is cancelled and its
294
+ * message removed from the transcript (marked `withdrawn` so other devices can
295
+ * tell a withdrawal from a deletion).
296
+ */
297
+ export async function withdrawQueuedRun(db, input) {
298
+ const now = new Date().toISOString();
299
+ const assistant = withRunMetadata(input.assistant, input.runId, 'cancelled');
300
+ await withPowerSyncWriteTransaction(db, async (tx) => {
301
+ await writeQueueMarker(tx, input.userMessageId, { state: 'withdrawn', runId: input.runId, executor: 'gateway', queuedAt: now }, { deletedAt: now });
302
+ await tx.execute(`UPDATE local_thread_run SET status = 'cancelled', finished_at = ?, updated_at = ?, assistant_snapshot_json = ?,
303
+ error_text = 'withdrawn by user', cleanup_after_at = ?
304
+ WHERE id = ?`, [now, now, safeJson(assistant), new Date(Date.parse(now) + RUN_RETENTION_MS).toISOString(), input.runId]);
305
+ });
306
+ return assistant;
307
+ }
308
+ /** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
309
+ export async function interruptOrphanedRuns(db, userId) {
310
+ const now = new Date().toISOString();
311
+ const rows = await db.getAll(`SELECT id, user_message_id FROM local_thread_run
312
+ WHERE user_id = ? AND status NOT IN ('succeeded', 'failed', 'cancelled', 'interrupted', 'expired')
313
+ AND request_payload_json LIKE '%"gatewayId"%'`, [userId]);
314
+ for (const row of rows) {
315
+ await db.execute(`UPDATE local_thread_run SET status = 'interrupted', error_text = ?, finished_at = ?, updated_at = ? WHERE id = ?`, ['gateway host restarted', now, now, row.id]);
316
+ // A turn that was still queued will never run: it must not show as queued forever.
317
+ if (row.user_message_id)
318
+ await writeQueueMarker(db, row.user_message_id, null);
319
+ }
320
+ return rows.length;
321
+ }
322
+ export async function readSessionBinding(db, threadId) {
323
+ const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
324
+ const parsed = parseJson(row?.robot_session_id);
325
+ if (parsed?.v !== 1 || typeof parsed.sessionId !== 'string' || typeof parsed.gatewayId !== 'string')
326
+ return null;
327
+ return {
328
+ sessionId: parsed.sessionId,
329
+ topicId: typeof parsed.topicId === 'string' ? parsed.topicId : null,
330
+ gatewayId: parsed.gatewayId,
331
+ };
332
+ }
333
+ export async function writeSessionBinding(db, threadId, binding) {
334
+ await db.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
335
+ safeJson({ v: 1, ...binding }),
336
+ new Date().toISOString(),
337
+ threadId,
338
+ ]);
339
+ }
340
+ /**
341
+ * The thread's current topic: the latest "new topic" marker the app wrote
342
+ * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
343
+ * starts a fresh agent session.
344
+ */
345
+ export async function readCurrentTopicId(db, threadId) {
346
+ const rows = await db.getAll(`SELECT id, meta_data FROM c_chat_message
347
+ WHERE type = ? AND source_id = ? AND entry_type = ? AND deleted_at IS NULL
348
+ ORDER BY julianday(created_at) DESC, id DESC LIMIT 20`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
349
+ for (const row of rows) {
350
+ if (parseJson(row.meta_data)?.kind === 'context_reset')
351
+ return row.id;
352
+ }
353
+ return null;
354
+ }
355
+ /** File parts resolve only to resources this user owns with an https storage URL. */
356
+ export async function resolveTrustedResource(db, input) {
357
+ const row = await db.getOptional(`SELECT storage_url, name, mime_type FROM c_resource
358
+ WHERE id = ? AND user_id = ? AND (is_del IS NULL OR is_del = 0) AND deleted_at IS NULL LIMIT 1`, [input.resourceId, input.userId]);
359
+ if (!row?.storage_url?.startsWith('https://'))
360
+ return null;
361
+ return { url: row.storage_url, name: row.name ?? 'attachment', mediaType: row.mime_type };
362
+ }
363
+ function robotMetaData(input) {
364
+ return JSON.stringify({ gateway: { machineId: input.machineId, ...(input.agentId ? { agentId: input.agentId } : {}) } });
365
+ }
366
+ export async function upsertRobot(db, input) {
367
+ const now = new Date().toISOString();
368
+ // The backend rejects NULL c_robot columns, and PowerSync omits NULLs only from inserts.
369
+ const cwd = input.cwd ?? '';
370
+ const metaData = robotMetaData(input);
371
+ const existing = await db.getOptional('SELECT name, device_id, device_name, agent_type, cwd, meta_data, is_del FROM c_robot WHERE id = ? LIMIT 1', [
372
+ input.id,
373
+ ]);
374
+ if (existing) {
375
+ const unchanged = existing.name === input.name
376
+ && existing.device_id === input.deviceId
377
+ && existing.device_name === input.deviceName
378
+ && existing.agent_type === input.agentType
379
+ && (existing.cwd ?? '') === cwd
380
+ && sameJson(existing.meta_data, metaData)
381
+ && !existing.is_del;
382
+ // Every daemon start re-registers; skip no-op writes so they do not queue uploads.
383
+ if (unchanged)
384
+ return;
385
+ // A removed gateway that is added again revives its robot (ids are deterministic).
386
+ await db.execute(`UPDATE c_robot SET name = ?, device_id = ?, device_name = ?, agent_type = ?, cwd = ?, meta_data = ?,
387
+ updated_at = ?, is_del = 0
388
+ WHERE id = ?`, [input.name, input.deviceId, input.deviceName, input.agentType, cwd, metaData, now, input.id]);
389
+ return;
390
+ }
391
+ await db.execute(`INSERT INTO c_robot (id, name, device_id, device_name, agent_type, cwd, config, meta_data, created_at, updated_at,
392
+ user_id, is_del)
393
+ VALUES (?, ?, ?, ?, ?, ?, '{}', ?, ?, ?, ?, 0)`, [input.id, input.name, input.deviceId, input.deviceName, input.agentType, cwd, metaData, now, now, input.userId]);
394
+ }
395
+ /**
396
+ * Soft-deletes this machine's gateway robots that are no longer configured
397
+ * (removed, or replaced before ids were deterministic), so the app never
398
+ * offers an agent that nothing hosts. Robots from other machines are untouched.
399
+ */
400
+ export async function retireRemovedRobots(db, input) {
401
+ const rows = await db.getAll(`SELECT id, meta_data FROM c_robot WHERE user_id = ? AND COALESCE(is_del, 0) = 0`, [input.userId]);
402
+ const keep = new Set(input.keepIds);
403
+ const retired = rows
404
+ .filter((row) => !keep.has(row.id) && robotMachineId(row.meta_data) === input.machineId)
405
+ .map((row) => row.id);
406
+ for (const id of retired)
407
+ await softDeleteRobot(db, id);
408
+ return retired;
409
+ }
410
+ /** The backend re-serializes JSON columns, so compare them parsed. */
411
+ function sameJson(stored, expected) {
412
+ try {
413
+ return JSON.stringify(JSON.parse(stored ?? '')) === expected;
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ }
419
+ function robotMachineId(metaData) {
420
+ try {
421
+ const parsed = JSON.parse(metaData ?? '');
422
+ return typeof parsed?.gateway?.machineId === 'string' ? parsed.gateway.machineId : null;
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ }
428
+ export async function softDeleteRobot(db, robotId) {
429
+ await db.execute('UPDATE c_robot SET is_del = 1, updated_at = ? WHERE id = ?', [new Date().toISOString(), robotId]);
430
+ }