@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.
- package/README.md +17 -3
- package/dist/cli.js +2 -0
- package/dist/commands/gateway/index.d.ts +4 -0
- package/dist/commands/gateway/index.js +300 -0
- package/dist/commands/schema/renderSkills.js +4 -0
- package/dist/commands/schema/spec.js +2 -0
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +1 -1
- package/dist/daemon/server.d.ts +1 -0
- package/dist/daemon/server.js +31 -0
- package/dist/gateway/adapters/acp.d.ts +42 -0
- package/dist/gateway/adapters/acp.js +285 -0
- package/dist/gateway/adapters/echo.d.ts +12 -0
- package/dist/gateway/adapters/echo.js +51 -0
- package/dist/gateway/adapters/index.d.ts +9 -0
- package/dist/gateway/adapters/index.js +30 -0
- package/dist/gateway/assistantProjection.d.ts +32 -0
- package/dist/gateway/assistantProjection.js +193 -0
- package/dist/gateway/config.d.ts +50 -0
- package/dist/gateway/config.js +253 -0
- package/dist/gateway/detect.d.ts +40 -0
- package/dist/gateway/detect.js +145 -0
- package/dist/gateway/deviceHost.d.ts +82 -0
- package/dist/gateway/deviceHost.js +530 -0
- package/dist/gateway/mcpServer.d.ts +4 -0
- package/dist/gateway/mcpServer.js +118 -0
- package/dist/gateway/protocol.d.ts +29 -0
- package/dist/gateway/protocol.js +127 -0
- package/dist/gateway/runExecutor.d.ts +67 -0
- package/dist/gateway/runExecutor.js +316 -0
- package/dist/gateway/service.d.ts +26 -0
- package/dist/gateway/service.js +82 -0
- package/dist/gateway/streamPublisher.d.ts +33 -0
- package/dist/gateway/streamPublisher.js +93 -0
- package/dist/gateway/threadStore.d.ts +157 -0
- package/dist/gateway/threadStore.js +430 -0
- package/dist/gateway/types.d.ts +117 -0
- package/dist/gateway/types.js +9 -0
- package/dist/vendor/electron/domain/properties.js +12 -1
- package/dist/vendor/electron/domain/saved-views.js +4 -1
- package/dist/vendor/electron/shared/external-types.d.ts +18 -0
- package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
- package/dist/vendor/electron/shared/note-properties.js +45 -0
- package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
- package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
- package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
- 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
|
+
}
|