codepiper 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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- package/scripts/postinstall-link-workspaces.mjs +58 -0
|
@@ -0,0 +1,2745 @@
|
|
|
1
|
+
import { Database as BunDatabase, type SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ProviderId, SessionHandle, SessionStatus } from "@codepiper/core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Event source types
|
|
8
|
+
*/
|
|
9
|
+
export type EventSource = "pty" | "hook" | "transcript" | "statusline";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Database event record
|
|
13
|
+
*/
|
|
14
|
+
export interface EventRecord {
|
|
15
|
+
id: number;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
timestamp: Date;
|
|
18
|
+
source: EventSource;
|
|
19
|
+
type: string;
|
|
20
|
+
payload: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionNotificationRecord {
|
|
24
|
+
id: number;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
eventType: string;
|
|
28
|
+
sourceEventId: number | null;
|
|
29
|
+
title: string;
|
|
30
|
+
body: string | null;
|
|
31
|
+
payload: Record<string, unknown>;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
readAt: Date | null;
|
|
34
|
+
readSource: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InsertSessionNotificationParams {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
provider: string;
|
|
40
|
+
eventType: string;
|
|
41
|
+
sourceEventId?: number;
|
|
42
|
+
title: string;
|
|
43
|
+
body?: string;
|
|
44
|
+
payload: Record<string, unknown>;
|
|
45
|
+
createdAt?: Date;
|
|
46
|
+
readAt?: Date;
|
|
47
|
+
readSource?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InsertSessionNotificationResult {
|
|
51
|
+
id: number;
|
|
52
|
+
inserted: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ListSessionNotificationsOptions {
|
|
56
|
+
sessionId?: string;
|
|
57
|
+
eventType?: string;
|
|
58
|
+
unreadOnly?: boolean;
|
|
59
|
+
before?: number;
|
|
60
|
+
limit?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MarkSessionNotificationsReadOptions {
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
readSource?: string;
|
|
66
|
+
readAt?: Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SessionNotificationCounts {
|
|
70
|
+
totalUnread: number;
|
|
71
|
+
bySession: Record<string, number>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SessionNotificationPrefsRecord {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
enabled: boolean | null;
|
|
77
|
+
updatedAt: Date;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PushSubscriptionRecord {
|
|
81
|
+
endpoint: string;
|
|
82
|
+
keys: {
|
|
83
|
+
p256dh: string;
|
|
84
|
+
auth: string;
|
|
85
|
+
};
|
|
86
|
+
expirationTime: number | null;
|
|
87
|
+
createdAt: Date;
|
|
88
|
+
updatedAt: Date;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UpsertPushSubscriptionParams {
|
|
92
|
+
endpoint: string;
|
|
93
|
+
keys: {
|
|
94
|
+
p256dh: string;
|
|
95
|
+
auth: string;
|
|
96
|
+
};
|
|
97
|
+
expirationTime?: number | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Session creation parameters
|
|
102
|
+
*/
|
|
103
|
+
export interface CreateSessionParams {
|
|
104
|
+
id: string;
|
|
105
|
+
provider: ProviderId;
|
|
106
|
+
cwd: string;
|
|
107
|
+
status: SessionStatus;
|
|
108
|
+
pid?: number;
|
|
109
|
+
ptyRows?: number;
|
|
110
|
+
ptyCols?: number;
|
|
111
|
+
transcriptPath?: string;
|
|
112
|
+
metadata?: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Session update parameters
|
|
117
|
+
*/
|
|
118
|
+
export interface UpdateSessionParams {
|
|
119
|
+
status?: SessionStatus;
|
|
120
|
+
pid?: number;
|
|
121
|
+
ptyRows?: number;
|
|
122
|
+
ptyCols?: number;
|
|
123
|
+
transcriptPath?: string;
|
|
124
|
+
metadata?: Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Session list filter options
|
|
129
|
+
*/
|
|
130
|
+
export interface ListSessionsOptions {
|
|
131
|
+
status?: SessionStatus;
|
|
132
|
+
provider?: ProviderId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Event insertion parameters
|
|
137
|
+
*/
|
|
138
|
+
export interface InsertEventParams {
|
|
139
|
+
sessionId: string;
|
|
140
|
+
source: EventSource;
|
|
141
|
+
type: string;
|
|
142
|
+
payload: unknown;
|
|
143
|
+
timestamp?: Date;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Event query options
|
|
148
|
+
*/
|
|
149
|
+
export interface GetEventsOptions {
|
|
150
|
+
since?: number;
|
|
151
|
+
before?: number;
|
|
152
|
+
limit?: number;
|
|
153
|
+
type?: string;
|
|
154
|
+
source?: EventSource;
|
|
155
|
+
order?: "asc" | "desc";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Transcript offset record
|
|
160
|
+
*/
|
|
161
|
+
export interface TranscriptOffset {
|
|
162
|
+
byteOffset: number;
|
|
163
|
+
lastLineHash: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Policy record
|
|
168
|
+
*/
|
|
169
|
+
export interface PolicyRecord {
|
|
170
|
+
id: string;
|
|
171
|
+
name: string;
|
|
172
|
+
description?: string;
|
|
173
|
+
enabled: boolean;
|
|
174
|
+
priority: number;
|
|
175
|
+
sessionId?: string;
|
|
176
|
+
rules: PolicyRule[];
|
|
177
|
+
createdAt: Date;
|
|
178
|
+
updatedAt: Date;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Policy rule
|
|
183
|
+
*/
|
|
184
|
+
export interface PolicyRule {
|
|
185
|
+
id: string;
|
|
186
|
+
action: "allow" | "deny" | "ask";
|
|
187
|
+
tool?: string | string[];
|
|
188
|
+
args?: Record<string, string | string[]>;
|
|
189
|
+
cwd?: string | string[];
|
|
190
|
+
session?: string | string[];
|
|
191
|
+
reason?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Policy creation parameters
|
|
196
|
+
*/
|
|
197
|
+
export interface CreatePolicyParams {
|
|
198
|
+
id: string;
|
|
199
|
+
name: string;
|
|
200
|
+
description?: string;
|
|
201
|
+
enabled?: boolean;
|
|
202
|
+
priority?: number;
|
|
203
|
+
sessionId?: string;
|
|
204
|
+
rules: PolicyRule[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Policy update parameters
|
|
209
|
+
*/
|
|
210
|
+
export interface UpdatePolicyParams {
|
|
211
|
+
name?: string;
|
|
212
|
+
description?: string;
|
|
213
|
+
enabled?: boolean;
|
|
214
|
+
priority?: number;
|
|
215
|
+
rules?: PolicyRule[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Policy list filter options
|
|
220
|
+
*/
|
|
221
|
+
export interface ListPoliciesOptions {
|
|
222
|
+
sessionId?: string;
|
|
223
|
+
enabled?: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Policy decision record (audit log)
|
|
228
|
+
*/
|
|
229
|
+
export interface PolicyDecisionRecord {
|
|
230
|
+
id: number;
|
|
231
|
+
sessionId: string;
|
|
232
|
+
eventId?: number;
|
|
233
|
+
policyId?: string;
|
|
234
|
+
toolName: string;
|
|
235
|
+
args?: Record<string, unknown>;
|
|
236
|
+
decision: "allow" | "deny" | "ask";
|
|
237
|
+
reason?: string;
|
|
238
|
+
timestamp: Date;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Policy decision insertion parameters
|
|
243
|
+
*/
|
|
244
|
+
export interface InsertPolicyDecisionParams {
|
|
245
|
+
sessionId: string;
|
|
246
|
+
eventId?: number;
|
|
247
|
+
policyId?: string;
|
|
248
|
+
toolName: string;
|
|
249
|
+
args?: Record<string, unknown>;
|
|
250
|
+
decision: "allow" | "deny" | "ask";
|
|
251
|
+
reason?: string;
|
|
252
|
+
timestamp?: Date;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Policy decision query options
|
|
257
|
+
*/
|
|
258
|
+
export interface GetPolicyDecisionsOptions {
|
|
259
|
+
since?: number;
|
|
260
|
+
limit?: number;
|
|
261
|
+
decision?: "allow" | "deny" | "ask";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Policy set record
|
|
266
|
+
*/
|
|
267
|
+
export interface PolicySetRecord {
|
|
268
|
+
id: string;
|
|
269
|
+
name: string;
|
|
270
|
+
description?: string;
|
|
271
|
+
isDefault: boolean;
|
|
272
|
+
createdAt: Date;
|
|
273
|
+
updatedAt: Date;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Policy set summary (with counts from joins)
|
|
278
|
+
*/
|
|
279
|
+
export interface PolicySetSummary {
|
|
280
|
+
id: string;
|
|
281
|
+
name: string;
|
|
282
|
+
description?: string;
|
|
283
|
+
isDefault: boolean;
|
|
284
|
+
policyCount: number;
|
|
285
|
+
sessionCount: number;
|
|
286
|
+
createdAt: Date;
|
|
287
|
+
updatedAt: Date;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Policy set creation parameters
|
|
292
|
+
*/
|
|
293
|
+
export interface CreatePolicySetParams {
|
|
294
|
+
id: string;
|
|
295
|
+
name: string;
|
|
296
|
+
description?: string;
|
|
297
|
+
isDefault?: boolean;
|
|
298
|
+
policyIds?: string[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Policy set update parameters
|
|
303
|
+
*/
|
|
304
|
+
export interface UpdatePolicySetParams {
|
|
305
|
+
name?: string;
|
|
306
|
+
description?: string;
|
|
307
|
+
isDefault?: boolean;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Token usage record
|
|
312
|
+
*/
|
|
313
|
+
export interface TokenUsageRecord {
|
|
314
|
+
id: number;
|
|
315
|
+
sessionId: string;
|
|
316
|
+
eventId?: number;
|
|
317
|
+
timestamp: Date;
|
|
318
|
+
model: string;
|
|
319
|
+
promptTokens: number;
|
|
320
|
+
completionTokens: number;
|
|
321
|
+
cacheCreationInputTokens: number;
|
|
322
|
+
cacheReadInputTokens: number;
|
|
323
|
+
totalTokens: number;
|
|
324
|
+
estimatedCostUsd?: number;
|
|
325
|
+
actualCostUsd?: number;
|
|
326
|
+
costDifferenceUsd?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Token usage insertion parameters
|
|
331
|
+
*/
|
|
332
|
+
export interface InsertTokenUsageParams {
|
|
333
|
+
sessionId: string;
|
|
334
|
+
eventId?: number;
|
|
335
|
+
timestamp?: Date;
|
|
336
|
+
model: string;
|
|
337
|
+
promptTokens: number;
|
|
338
|
+
completionTokens: number;
|
|
339
|
+
cacheCreationInputTokens?: number;
|
|
340
|
+
cacheReadInputTokens?: number;
|
|
341
|
+
totalTokens: number;
|
|
342
|
+
estimatedCostUsd?: number;
|
|
343
|
+
actualCostUsd?: number;
|
|
344
|
+
costDifferenceUsd?: number;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Token usage query options
|
|
349
|
+
*/
|
|
350
|
+
export interface GetTokenUsageOptions {
|
|
351
|
+
since?: Date;
|
|
352
|
+
until?: Date;
|
|
353
|
+
limit?: number;
|
|
354
|
+
model?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Model switch record
|
|
359
|
+
*/
|
|
360
|
+
export interface ModelSwitchRecord {
|
|
361
|
+
id: number;
|
|
362
|
+
sessionId: string;
|
|
363
|
+
timestamp: Date;
|
|
364
|
+
fromModel?: string;
|
|
365
|
+
toModel: string;
|
|
366
|
+
reason?: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Model switch insertion parameters
|
|
371
|
+
*/
|
|
372
|
+
export interface InsertModelSwitchParams {
|
|
373
|
+
sessionId: string;
|
|
374
|
+
timestamp?: Date;
|
|
375
|
+
fromModel?: string;
|
|
376
|
+
toModel: string;
|
|
377
|
+
reason?: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Transcript content record
|
|
382
|
+
*/
|
|
383
|
+
export interface TranscriptContentRecord {
|
|
384
|
+
id: number;
|
|
385
|
+
sessionId: string;
|
|
386
|
+
eventId?: number;
|
|
387
|
+
role: "user" | "assistant" | "system";
|
|
388
|
+
content: string;
|
|
389
|
+
timestamp: Date;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Transcript content insertion parameters
|
|
394
|
+
*/
|
|
395
|
+
export interface InsertTranscriptContentParams {
|
|
396
|
+
sessionId: string;
|
|
397
|
+
eventId?: number;
|
|
398
|
+
role: "user" | "assistant" | "system";
|
|
399
|
+
content: string;
|
|
400
|
+
timestamp?: Date;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Transcript content query options
|
|
405
|
+
*/
|
|
406
|
+
export interface GetTranscriptContentOptions {
|
|
407
|
+
since?: Date;
|
|
408
|
+
until?: Date;
|
|
409
|
+
limit?: number;
|
|
410
|
+
role?: "user" | "assistant" | "system";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Workspace record
|
|
415
|
+
*/
|
|
416
|
+
export interface WorkspaceRecord {
|
|
417
|
+
id: string;
|
|
418
|
+
name: string;
|
|
419
|
+
path: string;
|
|
420
|
+
createdAt: Date;
|
|
421
|
+
updatedAt: Date;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Workspace creation parameters
|
|
426
|
+
*/
|
|
427
|
+
export interface CreateWorkspaceParams {
|
|
428
|
+
id: string;
|
|
429
|
+
name: string;
|
|
430
|
+
path: string;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Workspace update parameters
|
|
435
|
+
*/
|
|
436
|
+
export interface UpdateWorkspaceParams {
|
|
437
|
+
name?: string;
|
|
438
|
+
path?: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Env set record (with masked values)
|
|
443
|
+
*/
|
|
444
|
+
export interface EnvSetRecord {
|
|
445
|
+
id: string;
|
|
446
|
+
name: string;
|
|
447
|
+
description?: string;
|
|
448
|
+
maskedVars: Record<string, string>;
|
|
449
|
+
varCount: number;
|
|
450
|
+
createdAt: Date;
|
|
451
|
+
updatedAt: Date;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Env set creation parameters
|
|
456
|
+
*/
|
|
457
|
+
export interface CreateEnvSetParams {
|
|
458
|
+
id: string;
|
|
459
|
+
name: string;
|
|
460
|
+
description?: string;
|
|
461
|
+
vars: Record<string, string>;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Env set update parameters
|
|
466
|
+
*/
|
|
467
|
+
export interface UpdateEnvSetParams {
|
|
468
|
+
name?: string;
|
|
469
|
+
description?: string;
|
|
470
|
+
vars?: Record<string, string>;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Auth config record
|
|
475
|
+
*/
|
|
476
|
+
export interface AuthConfigRecord {
|
|
477
|
+
passwordHash: string;
|
|
478
|
+
totpSecretEncrypted: string | null;
|
|
479
|
+
totpEnabled: boolean;
|
|
480
|
+
mfaSetupPending: boolean;
|
|
481
|
+
onboardingTokenHash: string | null;
|
|
482
|
+
onboardingTokenExpiresAt: number | null;
|
|
483
|
+
recoveryCodesEncrypted: string | null;
|
|
484
|
+
createdAt: Date;
|
|
485
|
+
updatedAt: Date;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Auth session record
|
|
490
|
+
*/
|
|
491
|
+
export interface AuthSessionRecord {
|
|
492
|
+
tokenHash: string;
|
|
493
|
+
createdAt: number;
|
|
494
|
+
expiresAt: number;
|
|
495
|
+
lastUsedAt: number;
|
|
496
|
+
ipAddress: string | null;
|
|
497
|
+
userAgent: string | null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Daemon settings record
|
|
502
|
+
*/
|
|
503
|
+
export interface DaemonTerminalFeatureSettings {
|
|
504
|
+
wsPtyPasteEnabled: boolean;
|
|
505
|
+
latencyProbesEnabled: boolean;
|
|
506
|
+
diagnosticsPanelEnabled: boolean;
|
|
507
|
+
codexAppServerSpikeEnabled: boolean;
|
|
508
|
+
wsPtyPasteCanaryPercent: number;
|
|
509
|
+
latencyProbesCanaryPercent: number;
|
|
510
|
+
diagnosticsPanelCanaryPercent: number;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export interface DaemonSettingsRecord {
|
|
514
|
+
preserveSessions: boolean;
|
|
515
|
+
defaultPolicyAction: "ask" | "deny";
|
|
516
|
+
forwardSshAuthSock: boolean;
|
|
517
|
+
codexHostAccessProfileEnabled: boolean;
|
|
518
|
+
terminalFeatures: DaemonTerminalFeatureSettings;
|
|
519
|
+
notificationsEnabled: boolean;
|
|
520
|
+
systemNotificationsEnabled: boolean;
|
|
521
|
+
notificationSoundsEnabled: boolean;
|
|
522
|
+
notificationEventDefaults: Record<string, boolean>;
|
|
523
|
+
notificationSoundMap: Record<string, string>;
|
|
524
|
+
updatedAt: Date;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Database interface for session and event management
|
|
529
|
+
*/
|
|
530
|
+
export interface IDatabase {
|
|
531
|
+
init(): Promise<void>;
|
|
532
|
+
close(): void;
|
|
533
|
+
|
|
534
|
+
// Session operations
|
|
535
|
+
createSession(params: CreateSessionParams): void;
|
|
536
|
+
getSession(id: string): SessionHandle | undefined;
|
|
537
|
+
updateSession(id: string, params: UpdateSessionParams): void;
|
|
538
|
+
deleteSession(id: string): void;
|
|
539
|
+
listSessions(options?: ListSessionsOptions): SessionHandle[];
|
|
540
|
+
cleanupOldSessions(olderThanMs: number): number;
|
|
541
|
+
|
|
542
|
+
// Event operations
|
|
543
|
+
insertEvent(params: InsertEventParams): number;
|
|
544
|
+
getEventsBySessionId(sessionId: string, options?: GetEventsOptions): EventRecord[];
|
|
545
|
+
|
|
546
|
+
// Session notification operations
|
|
547
|
+
insertSessionNotification(params: InsertSessionNotificationParams): number;
|
|
548
|
+
insertSessionNotificationWithStatus(
|
|
549
|
+
params: InsertSessionNotificationParams
|
|
550
|
+
): InsertSessionNotificationResult;
|
|
551
|
+
listSessionNotifications(options?: ListSessionNotificationsOptions): SessionNotificationRecord[];
|
|
552
|
+
getSessionNotificationCounts(): SessionNotificationCounts;
|
|
553
|
+
markSessionNotificationRead(notificationId: number, readSource?: string, readAt?: Date): boolean;
|
|
554
|
+
markSessionNotificationsRead(options?: MarkSessionNotificationsReadOptions): number;
|
|
555
|
+
getSessionNotificationPrefs(sessionId: string): SessionNotificationPrefsRecord;
|
|
556
|
+
setSessionNotificationPrefs(
|
|
557
|
+
sessionId: string,
|
|
558
|
+
enabled: boolean | null
|
|
559
|
+
): SessionNotificationPrefsRecord;
|
|
560
|
+
listPushSubscriptions(): PushSubscriptionRecord[];
|
|
561
|
+
upsertPushSubscription(params: UpsertPushSubscriptionParams): PushSubscriptionRecord;
|
|
562
|
+
deletePushSubscription(endpoint: string): boolean;
|
|
563
|
+
|
|
564
|
+
// Transcript offset operations
|
|
565
|
+
getTranscriptOffset(sessionId: string, path: string): TranscriptOffset;
|
|
566
|
+
updateTranscriptOffset(sessionId: string, path: string, offset: TranscriptOffset): void;
|
|
567
|
+
|
|
568
|
+
// Policy operations
|
|
569
|
+
createPolicy(params: CreatePolicyParams): void;
|
|
570
|
+
getPolicy(id: string): PolicyRecord | undefined;
|
|
571
|
+
updatePolicy(id: string, params: UpdatePolicyParams): void;
|
|
572
|
+
deletePolicy(id: string): void;
|
|
573
|
+
listPolicies(options?: ListPoliciesOptions): PolicyRecord[];
|
|
574
|
+
|
|
575
|
+
// Policy decision operations (audit log)
|
|
576
|
+
insertPolicyDecision(params: InsertPolicyDecisionParams): number;
|
|
577
|
+
getPolicyDecisionsBySessionId(
|
|
578
|
+
sessionId: string,
|
|
579
|
+
options?: GetPolicyDecisionsOptions
|
|
580
|
+
): PolicyDecisionRecord[];
|
|
581
|
+
|
|
582
|
+
// Policy set operations
|
|
583
|
+
createPolicySet(params: CreatePolicySetParams): void;
|
|
584
|
+
getPolicySet(id: string): PolicySetRecord | undefined;
|
|
585
|
+
updatePolicySet(id: string, params: UpdatePolicySetParams): void;
|
|
586
|
+
deletePolicySet(id: string): void;
|
|
587
|
+
listPolicySets(): PolicySetSummary[];
|
|
588
|
+
addPolicyToSet(setId: string, policyId: string): void;
|
|
589
|
+
removePolicyFromSet(setId: string, policyId: string): void;
|
|
590
|
+
getPolicySetMembers(setId: string): PolicyRecord[];
|
|
591
|
+
applyPolicySetToSession(sessionId: string, setId: string): void;
|
|
592
|
+
removePolicySetFromSession(sessionId: string, setId: string): void;
|
|
593
|
+
getSessionPolicySets(sessionId: string): PolicySetSummary[];
|
|
594
|
+
getEffectivePolicies(sessionId: string): PolicyRecord[];
|
|
595
|
+
getDefaultPolicySet(): PolicySetRecord | undefined;
|
|
596
|
+
|
|
597
|
+
// Token usage operations
|
|
598
|
+
insertTokenUsage(params: InsertTokenUsageParams): number;
|
|
599
|
+
getTokenUsageBySessionId(sessionId: string, options?: GetTokenUsageOptions): TokenUsageRecord[];
|
|
600
|
+
getTotalTokensBySessionId(sessionId: string): {
|
|
601
|
+
totalTokens: number;
|
|
602
|
+
totalCost: number;
|
|
603
|
+
byModel: Record<string, { tokens: number; cost: number }>;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Model switch operations
|
|
607
|
+
insertModelSwitch(params: InsertModelSwitchParams): number;
|
|
608
|
+
getModelSwitchesBySessionId(sessionId: string): ModelSwitchRecord[];
|
|
609
|
+
|
|
610
|
+
// Transcript content operations
|
|
611
|
+
insertTranscriptContent(params: InsertTranscriptContentParams): number;
|
|
612
|
+
getTranscriptContentBySessionId(
|
|
613
|
+
sessionId: string,
|
|
614
|
+
options?: GetTranscriptContentOptions
|
|
615
|
+
): TranscriptContentRecord[];
|
|
616
|
+
|
|
617
|
+
// Workspace operations
|
|
618
|
+
createWorkspace(params: CreateWorkspaceParams): void;
|
|
619
|
+
getWorkspace(id: string): WorkspaceRecord | undefined;
|
|
620
|
+
updateWorkspace(id: string, params: UpdateWorkspaceParams): void;
|
|
621
|
+
deleteWorkspace(id: string): void;
|
|
622
|
+
listWorkspaces(): WorkspaceRecord[];
|
|
623
|
+
|
|
624
|
+
// Env set operations
|
|
625
|
+
createEnvSet(params: CreateEnvSetParams): void;
|
|
626
|
+
getEnvSet(id: string): EnvSetRecord | undefined;
|
|
627
|
+
updateEnvSet(id: string, params: UpdateEnvSetParams): void;
|
|
628
|
+
deleteEnvSet(id: string): void;
|
|
629
|
+
listEnvSets(): EnvSetRecord[];
|
|
630
|
+
decryptEnvSetVars(id: string): Record<string, string>;
|
|
631
|
+
|
|
632
|
+
// Auth operations
|
|
633
|
+
hasAuthConfig(): boolean;
|
|
634
|
+
getAuthConfig(): AuthConfigRecord | null;
|
|
635
|
+
createAuthConfig(
|
|
636
|
+
passwordHash: string,
|
|
637
|
+
options?: {
|
|
638
|
+
mfaSetupPending?: boolean;
|
|
639
|
+
onboardingTokenHash?: string | null;
|
|
640
|
+
onboardingTokenExpiresAt?: number | null;
|
|
641
|
+
}
|
|
642
|
+
): void;
|
|
643
|
+
updateAuthPassword(passwordHash: string): void;
|
|
644
|
+
updateAuthTotp(
|
|
645
|
+
encryptedSecret: string | null,
|
|
646
|
+
enabled: boolean,
|
|
647
|
+
recoveryCodes: string | null
|
|
648
|
+
): void;
|
|
649
|
+
updateAuthOnboardingState(
|
|
650
|
+
mfaSetupPending: boolean,
|
|
651
|
+
onboardingTokenHash: string | null,
|
|
652
|
+
onboardingTokenExpiresAt: number | null
|
|
653
|
+
): void;
|
|
654
|
+
createAuthSession(
|
|
655
|
+
tokenHash: string,
|
|
656
|
+
ip: string | null,
|
|
657
|
+
userAgent: string | null,
|
|
658
|
+
expiresAt: number
|
|
659
|
+
): void;
|
|
660
|
+
getAuthSession(tokenHash: string): AuthSessionRecord | null;
|
|
661
|
+
touchAuthSession(tokenHash: string, newExpiresAt: number): void;
|
|
662
|
+
deleteAuthSession(tokenHash: string): void;
|
|
663
|
+
deleteAllAuthSessions(): void;
|
|
664
|
+
listAuthSessions(): AuthSessionRecord[];
|
|
665
|
+
cleanupExpiredAuthSessions(): number;
|
|
666
|
+
|
|
667
|
+
// Daemon settings operations
|
|
668
|
+
getDaemonSettings(): DaemonSettingsRecord;
|
|
669
|
+
updateDaemonSettings(params: {
|
|
670
|
+
preserveSessions?: boolean;
|
|
671
|
+
defaultPolicyAction?: "ask" | "deny";
|
|
672
|
+
forwardSshAuthSock?: boolean;
|
|
673
|
+
codexHostAccessProfileEnabled?: boolean;
|
|
674
|
+
terminalFeatures?: Partial<DaemonTerminalFeatureSettings>;
|
|
675
|
+
notificationsEnabled?: boolean;
|
|
676
|
+
systemNotificationsEnabled?: boolean;
|
|
677
|
+
notificationSoundsEnabled?: boolean;
|
|
678
|
+
notificationEventDefaults?: Record<string, boolean>;
|
|
679
|
+
notificationSoundMap?: Record<string, string>;
|
|
680
|
+
}): void;
|
|
681
|
+
|
|
682
|
+
// Utility (for testing)
|
|
683
|
+
query(sql: string): unknown[];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* SQLite-backed database implementation
|
|
688
|
+
*/
|
|
689
|
+
export class Database implements IDatabase {
|
|
690
|
+
public readonly db: BunDatabase;
|
|
691
|
+
private schemaPath: string;
|
|
692
|
+
|
|
693
|
+
constructor(dbPath: string = ":memory:") {
|
|
694
|
+
this.db = new BunDatabase(dbPath);
|
|
695
|
+
this.schemaPath = join(dirname(__filename), "schema.sql");
|
|
696
|
+
|
|
697
|
+
// Enable foreign keys
|
|
698
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Initialize database schema
|
|
703
|
+
*/
|
|
704
|
+
async init(): Promise<void> {
|
|
705
|
+
const schema = readFileSync(this.schemaPath, "utf-8");
|
|
706
|
+
this.db.exec(schema);
|
|
707
|
+
|
|
708
|
+
// Migrate: add default_policy_action column to daemon_settings (for existing databases)
|
|
709
|
+
try {
|
|
710
|
+
this.db.exec(
|
|
711
|
+
"ALTER TABLE daemon_settings ADD COLUMN default_policy_action TEXT NOT NULL DEFAULT 'ask'"
|
|
712
|
+
);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
715
|
+
if (!msg.includes("duplicate column name")) {
|
|
716
|
+
throw new Error(`Failed to migrate daemon_settings table: ${msg}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const daemonSettingsMigrations = [
|
|
721
|
+
"ALTER TABLE daemon_settings ADD COLUMN forward_ssh_auth_sock INTEGER NOT NULL DEFAULT 1",
|
|
722
|
+
"ALTER TABLE daemon_settings ADD COLUMN codex_host_access_profile_enabled INTEGER NOT NULL DEFAULT 0",
|
|
723
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_ws_pty_paste_enabled INTEGER NOT NULL DEFAULT 1",
|
|
724
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_latency_probes_enabled INTEGER NOT NULL DEFAULT 1",
|
|
725
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_diagnostics_panel_enabled INTEGER NOT NULL DEFAULT 0",
|
|
726
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_codex_app_server_spike_enabled INTEGER NOT NULL DEFAULT 0",
|
|
727
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_ws_pty_paste_canary_percent INTEGER NOT NULL DEFAULT 100",
|
|
728
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_latency_probes_canary_percent INTEGER NOT NULL DEFAULT 100",
|
|
729
|
+
"ALTER TABLE daemon_settings ADD COLUMN terminal_diagnostics_panel_canary_percent INTEGER NOT NULL DEFAULT 0",
|
|
730
|
+
"ALTER TABLE daemon_settings ADD COLUMN notifications_enabled INTEGER NOT NULL DEFAULT 0",
|
|
731
|
+
"ALTER TABLE daemon_settings ADD COLUMN system_notifications_enabled INTEGER NOT NULL DEFAULT 0",
|
|
732
|
+
"ALTER TABLE daemon_settings ADD COLUMN notification_sounds_enabled INTEGER NOT NULL DEFAULT 1",
|
|
733
|
+
"ALTER TABLE daemon_settings ADD COLUMN notification_event_defaults_json TEXT NOT NULL DEFAULT '{}'",
|
|
734
|
+
"ALTER TABLE daemon_settings ADD COLUMN notification_sound_map_json TEXT NOT NULL DEFAULT '{}'",
|
|
735
|
+
] as const;
|
|
736
|
+
|
|
737
|
+
for (const migration of daemonSettingsMigrations) {
|
|
738
|
+
try {
|
|
739
|
+
this.db.exec(migration);
|
|
740
|
+
} catch (err) {
|
|
741
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
742
|
+
if (!msg.includes("duplicate column name")) {
|
|
743
|
+
throw new Error(`Failed to migrate daemon_settings table: ${msg}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const authConfigMigrations = [
|
|
749
|
+
"ALTER TABLE auth_config ADD COLUMN mfa_setup_pending INTEGER NOT NULL DEFAULT 0",
|
|
750
|
+
"ALTER TABLE auth_config ADD COLUMN onboarding_token_hash TEXT",
|
|
751
|
+
"ALTER TABLE auth_config ADD COLUMN onboarding_token_expires_at INTEGER",
|
|
752
|
+
] as const;
|
|
753
|
+
|
|
754
|
+
for (const migration of authConfigMigrations) {
|
|
755
|
+
try {
|
|
756
|
+
this.db.exec(migration);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
759
|
+
if (!msg.includes("duplicate column name")) {
|
|
760
|
+
throw new Error(`Failed to migrate auth_config table: ${msg}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Close database connection
|
|
768
|
+
*/
|
|
769
|
+
close(): void {
|
|
770
|
+
this.db.close();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Execute raw query (for testing)
|
|
775
|
+
*/
|
|
776
|
+
query(sql: string): unknown[] {
|
|
777
|
+
return this.db.query(sql).all();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Create a new session
|
|
782
|
+
*/
|
|
783
|
+
createSession(params: CreateSessionParams): void {
|
|
784
|
+
const now = Date.now();
|
|
785
|
+
const stmt = this.db.prepare(`
|
|
786
|
+
INSERT INTO sessions (
|
|
787
|
+
id, provider, cwd, status, created_at, updated_at,
|
|
788
|
+
pid, pty_cols, pty_rows, transcript_path, metadata_json
|
|
789
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
790
|
+
`);
|
|
791
|
+
|
|
792
|
+
stmt.run(
|
|
793
|
+
params.id,
|
|
794
|
+
params.provider,
|
|
795
|
+
params.cwd,
|
|
796
|
+
params.status,
|
|
797
|
+
now,
|
|
798
|
+
now,
|
|
799
|
+
params.pid ?? null,
|
|
800
|
+
params.ptyCols ?? null,
|
|
801
|
+
params.ptyRows ?? null,
|
|
802
|
+
params.transcriptPath ?? null,
|
|
803
|
+
params.metadata ? JSON.stringify(params.metadata) : null
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Get session by ID
|
|
809
|
+
*/
|
|
810
|
+
getSession(id: string): SessionHandle | undefined {
|
|
811
|
+
const stmt = this.db.prepare(`
|
|
812
|
+
SELECT * FROM sessions WHERE id = ?
|
|
813
|
+
`);
|
|
814
|
+
|
|
815
|
+
const row = stmt.get(id) as any;
|
|
816
|
+
if (!row) return undefined;
|
|
817
|
+
|
|
818
|
+
return this.mapRowToSessionHandle(row);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Update session fields
|
|
823
|
+
*/
|
|
824
|
+
updateSession(id: string, params: UpdateSessionParams): void {
|
|
825
|
+
const updates: string[] = [];
|
|
826
|
+
const values: SQLQueryBindings[] = [];
|
|
827
|
+
|
|
828
|
+
if (params.status !== undefined) {
|
|
829
|
+
updates.push("status = ?");
|
|
830
|
+
values.push(params.status);
|
|
831
|
+
}
|
|
832
|
+
if (params.pid !== undefined) {
|
|
833
|
+
updates.push("pid = ?");
|
|
834
|
+
values.push(params.pid);
|
|
835
|
+
}
|
|
836
|
+
if (params.ptyRows !== undefined) {
|
|
837
|
+
updates.push("pty_rows = ?");
|
|
838
|
+
values.push(params.ptyRows);
|
|
839
|
+
}
|
|
840
|
+
if (params.ptyCols !== undefined) {
|
|
841
|
+
updates.push("pty_cols = ?");
|
|
842
|
+
values.push(params.ptyCols);
|
|
843
|
+
}
|
|
844
|
+
if (params.transcriptPath !== undefined) {
|
|
845
|
+
updates.push("transcript_path = ?");
|
|
846
|
+
values.push(params.transcriptPath);
|
|
847
|
+
}
|
|
848
|
+
if (params.metadata !== undefined) {
|
|
849
|
+
updates.push("metadata_json = ?");
|
|
850
|
+
values.push(JSON.stringify(params.metadata));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Always update updated_at
|
|
854
|
+
updates.push("updated_at = ?");
|
|
855
|
+
values.push(Date.now());
|
|
856
|
+
|
|
857
|
+
if (updates.length === 1) {
|
|
858
|
+
// Only updated_at changed, still execute
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
values.push(id);
|
|
862
|
+
|
|
863
|
+
const sql = `UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`;
|
|
864
|
+
const stmt = this.db.prepare(sql);
|
|
865
|
+
stmt.run(...values);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Delete session
|
|
870
|
+
*/
|
|
871
|
+
deleteSession(id: string): void {
|
|
872
|
+
const stmt = this.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
873
|
+
stmt.run(id);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Clean up old STOPPED or CRASHED sessions
|
|
878
|
+
* @param olderThanMs - Delete sessions older than this many milliseconds
|
|
879
|
+
* @returns Number of sessions deleted
|
|
880
|
+
*/
|
|
881
|
+
cleanupOldSessions(olderThanMs: number): number {
|
|
882
|
+
const cutoffTimestamp = Date.now() - olderThanMs;
|
|
883
|
+
|
|
884
|
+
const stmt = this.db.prepare(`
|
|
885
|
+
DELETE FROM sessions
|
|
886
|
+
WHERE (status = 'STOPPED' OR status = 'CRASHED')
|
|
887
|
+
AND updated_at < ?
|
|
888
|
+
`);
|
|
889
|
+
|
|
890
|
+
const result = stmt.run(cutoffTimestamp);
|
|
891
|
+
return result.changes;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* List sessions with optional filters
|
|
896
|
+
*/
|
|
897
|
+
listSessions(options: ListSessionsOptions = {}): SessionHandle[] {
|
|
898
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
899
|
+
const values: SQLQueryBindings[] = [];
|
|
900
|
+
|
|
901
|
+
if (options.status) {
|
|
902
|
+
sql += " AND status = ?";
|
|
903
|
+
values.push(options.status);
|
|
904
|
+
}
|
|
905
|
+
if (options.provider) {
|
|
906
|
+
sql += " AND provider = ?";
|
|
907
|
+
values.push(options.provider);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
sql += " ORDER BY created_at DESC";
|
|
911
|
+
|
|
912
|
+
const stmt = this.db.prepare(sql);
|
|
913
|
+
const rows = stmt.all(...values) as any[];
|
|
914
|
+
|
|
915
|
+
return rows.map((row) => this.mapRowToSessionHandle(row));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Insert a new event
|
|
920
|
+
*/
|
|
921
|
+
insertEvent(params: InsertEventParams): number {
|
|
922
|
+
const timestamp = params.timestamp ?? new Date();
|
|
923
|
+
const stmt = this.db.prepare(`
|
|
924
|
+
INSERT INTO events (session_id, ts, source, type, payload_json)
|
|
925
|
+
VALUES (?, ?, ?, ?, ?)
|
|
926
|
+
`);
|
|
927
|
+
|
|
928
|
+
stmt.run(
|
|
929
|
+
params.sessionId,
|
|
930
|
+
timestamp.getTime(),
|
|
931
|
+
params.source,
|
|
932
|
+
params.type,
|
|
933
|
+
JSON.stringify(params.payload)
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// Get last inserted row id
|
|
937
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
938
|
+
return result.id;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Get events for a session with optional filters
|
|
943
|
+
*/
|
|
944
|
+
getEventsBySessionId(sessionId: string, options: GetEventsOptions = {}): EventRecord[] {
|
|
945
|
+
let sql = "SELECT * FROM events WHERE session_id = ?";
|
|
946
|
+
const values: SQLQueryBindings[] = [sessionId];
|
|
947
|
+
|
|
948
|
+
if (options.since !== undefined) {
|
|
949
|
+
sql += " AND id > ?";
|
|
950
|
+
values.push(options.since);
|
|
951
|
+
}
|
|
952
|
+
if (options.before !== undefined) {
|
|
953
|
+
sql += " AND id < ?";
|
|
954
|
+
values.push(options.before);
|
|
955
|
+
}
|
|
956
|
+
if (options.type) {
|
|
957
|
+
sql += " AND type = ?";
|
|
958
|
+
values.push(options.type);
|
|
959
|
+
}
|
|
960
|
+
if (options.source) {
|
|
961
|
+
sql += " AND source = ?";
|
|
962
|
+
values.push(options.source);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const order = options.order === "desc" ? "DESC" : "ASC";
|
|
966
|
+
sql += ` ORDER BY id ${order}`;
|
|
967
|
+
|
|
968
|
+
if (options.limit) {
|
|
969
|
+
sql += " LIMIT ?";
|
|
970
|
+
values.push(options.limit);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const stmt = this.db.prepare(sql);
|
|
974
|
+
const rows = stmt.all(...values) as any[];
|
|
975
|
+
|
|
976
|
+
return rows.map((row) => this.mapRowToEventRecord(row));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Insert a user-facing session notification
|
|
981
|
+
*/
|
|
982
|
+
insertSessionNotification(params: InsertSessionNotificationParams): number {
|
|
983
|
+
return this.insertSessionNotificationWithStatus(params).id;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Insert a user-facing session notification and report whether it was inserted.
|
|
988
|
+
* When sourceEventId is present, treat (sourceEventId, eventType) as idempotent.
|
|
989
|
+
*/
|
|
990
|
+
insertSessionNotificationWithStatus(
|
|
991
|
+
params: InsertSessionNotificationParams
|
|
992
|
+
): InsertSessionNotificationResult {
|
|
993
|
+
const sourceEventId = this.normalizeSourceEventId(params.sourceEventId);
|
|
994
|
+
if (sourceEventId !== null) {
|
|
995
|
+
const existingId = this.findSessionNotificationIdBySourceEvent(
|
|
996
|
+
sourceEventId,
|
|
997
|
+
params.eventType
|
|
998
|
+
);
|
|
999
|
+
if (existingId !== null) {
|
|
1000
|
+
return { id: existingId, inserted: false };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const createdAt = params.createdAt ?? new Date();
|
|
1005
|
+
const stmt = this.db.prepare(`
|
|
1006
|
+
INSERT INTO session_notifications (
|
|
1007
|
+
session_id,
|
|
1008
|
+
provider,
|
|
1009
|
+
event_type,
|
|
1010
|
+
source_event_id,
|
|
1011
|
+
title,
|
|
1012
|
+
body,
|
|
1013
|
+
payload_json,
|
|
1014
|
+
created_at,
|
|
1015
|
+
read_at,
|
|
1016
|
+
read_source
|
|
1017
|
+
)
|
|
1018
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1019
|
+
`);
|
|
1020
|
+
|
|
1021
|
+
stmt.run(
|
|
1022
|
+
params.sessionId,
|
|
1023
|
+
params.provider,
|
|
1024
|
+
params.eventType,
|
|
1025
|
+
sourceEventId,
|
|
1026
|
+
params.title,
|
|
1027
|
+
params.body ?? null,
|
|
1028
|
+
JSON.stringify(params.payload),
|
|
1029
|
+
createdAt.getTime(),
|
|
1030
|
+
params.readAt ? params.readAt.getTime() : null,
|
|
1031
|
+
params.readSource ?? null
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
1035
|
+
return { id: result.id, inserted: true };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* List session notifications with optional filters
|
|
1040
|
+
*/
|
|
1041
|
+
listSessionNotifications(
|
|
1042
|
+
options: ListSessionNotificationsOptions = {}
|
|
1043
|
+
): SessionNotificationRecord[] {
|
|
1044
|
+
let sql = "SELECT * FROM session_notifications WHERE 1=1";
|
|
1045
|
+
const values: SQLQueryBindings[] = [];
|
|
1046
|
+
|
|
1047
|
+
if (options.sessionId) {
|
|
1048
|
+
sql += " AND session_id = ?";
|
|
1049
|
+
values.push(options.sessionId);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (options.eventType) {
|
|
1053
|
+
sql += " AND event_type = ?";
|
|
1054
|
+
values.push(options.eventType);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (options.unreadOnly) {
|
|
1058
|
+
sql += " AND read_at IS NULL";
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (options.before !== undefined) {
|
|
1062
|
+
sql += " AND id < ?";
|
|
1063
|
+
values.push(options.before);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
sql += " ORDER BY id DESC";
|
|
1067
|
+
|
|
1068
|
+
if (options.limit !== undefined) {
|
|
1069
|
+
const rawLimit = Number.isFinite(options.limit) ? options.limit : 0;
|
|
1070
|
+
const normalizedLimit = Math.max(0, Math.min(Math.floor(rawLimit), 200));
|
|
1071
|
+
sql += " LIMIT ?";
|
|
1072
|
+
values.push(normalizedLimit);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const rows = this.db.prepare(sql).all(...values) as any[];
|
|
1076
|
+
return rows.map((row) => this.mapRowToSessionNotificationRecord(row));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Get unread notification counts globally and by session
|
|
1081
|
+
*/
|
|
1082
|
+
getSessionNotificationCounts(): SessionNotificationCounts {
|
|
1083
|
+
const totalRow = this.db
|
|
1084
|
+
.prepare(
|
|
1085
|
+
`
|
|
1086
|
+
SELECT COUNT(*) as count
|
|
1087
|
+
FROM session_notifications
|
|
1088
|
+
WHERE read_at IS NULL
|
|
1089
|
+
`
|
|
1090
|
+
)
|
|
1091
|
+
.get() as any;
|
|
1092
|
+
|
|
1093
|
+
const bySessionRows = this.db
|
|
1094
|
+
.prepare(
|
|
1095
|
+
`
|
|
1096
|
+
SELECT session_id, COUNT(*) as count
|
|
1097
|
+
FROM session_notifications
|
|
1098
|
+
WHERE read_at IS NULL
|
|
1099
|
+
GROUP BY session_id
|
|
1100
|
+
`
|
|
1101
|
+
)
|
|
1102
|
+
.all() as any[];
|
|
1103
|
+
|
|
1104
|
+
const bySession: Record<string, number> = {};
|
|
1105
|
+
for (const row of bySessionRows) {
|
|
1106
|
+
bySession[row.session_id] = Number(row.count ?? 0);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
totalUnread: Number(totalRow?.count ?? 0),
|
|
1111
|
+
bySession,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Mark one notification as read (idempotent)
|
|
1117
|
+
*/
|
|
1118
|
+
markSessionNotificationRead(
|
|
1119
|
+
notificationId: number,
|
|
1120
|
+
readSource: string = "click",
|
|
1121
|
+
readAt: Date = new Date()
|
|
1122
|
+
): boolean {
|
|
1123
|
+
const result = this.db
|
|
1124
|
+
.prepare(
|
|
1125
|
+
`
|
|
1126
|
+
UPDATE session_notifications
|
|
1127
|
+
SET read_at = ?, read_source = ?
|
|
1128
|
+
WHERE id = ? AND read_at IS NULL
|
|
1129
|
+
`
|
|
1130
|
+
)
|
|
1131
|
+
.run(readAt.getTime(), readSource, notificationId);
|
|
1132
|
+
|
|
1133
|
+
return result.changes > 0;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Mark notifications as read in bulk (idempotent)
|
|
1138
|
+
*/
|
|
1139
|
+
markSessionNotificationsRead(options: MarkSessionNotificationsReadOptions = {}): number {
|
|
1140
|
+
let sql = `
|
|
1141
|
+
UPDATE session_notifications
|
|
1142
|
+
SET read_at = ?, read_source = ?
|
|
1143
|
+
WHERE read_at IS NULL
|
|
1144
|
+
`;
|
|
1145
|
+
const values: SQLQueryBindings[] = [
|
|
1146
|
+
(options.readAt ?? new Date()).getTime(),
|
|
1147
|
+
options.readSource ?? "bulk",
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
if (options.sessionId) {
|
|
1151
|
+
sql += " AND session_id = ?";
|
|
1152
|
+
values.push(options.sessionId);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const result = this.db.prepare(sql).run(...values);
|
|
1156
|
+
return result.changes;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private normalizeSourceEventId(sourceEventId: unknown): number | null {
|
|
1160
|
+
if (!Number.isInteger(sourceEventId) || (sourceEventId as number) <= 0) {
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
return sourceEventId as number;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private findSessionNotificationIdBySourceEvent(
|
|
1167
|
+
sourceEventId: number,
|
|
1168
|
+
eventType: string
|
|
1169
|
+
): number | null {
|
|
1170
|
+
const row = this.db
|
|
1171
|
+
.prepare(
|
|
1172
|
+
`
|
|
1173
|
+
SELECT id
|
|
1174
|
+
FROM session_notifications
|
|
1175
|
+
WHERE source_event_id = ? AND event_type = ?
|
|
1176
|
+
ORDER BY id ASC
|
|
1177
|
+
LIMIT 1
|
|
1178
|
+
`
|
|
1179
|
+
)
|
|
1180
|
+
.get(sourceEventId, eventType) as { id?: unknown } | null;
|
|
1181
|
+
|
|
1182
|
+
if (!(row && Number.isInteger(row.id))) {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return row.id as number;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Get per-session notification preference override
|
|
1191
|
+
*/
|
|
1192
|
+
getSessionNotificationPrefs(sessionId: string): SessionNotificationPrefsRecord {
|
|
1193
|
+
const row = this.db
|
|
1194
|
+
.prepare(
|
|
1195
|
+
`
|
|
1196
|
+
SELECT session_id, enabled, updated_at
|
|
1197
|
+
FROM session_notification_prefs
|
|
1198
|
+
WHERE session_id = ?
|
|
1199
|
+
`
|
|
1200
|
+
)
|
|
1201
|
+
.get(sessionId) as any;
|
|
1202
|
+
|
|
1203
|
+
if (!row) {
|
|
1204
|
+
return {
|
|
1205
|
+
sessionId,
|
|
1206
|
+
enabled: null,
|
|
1207
|
+
updatedAt: new Date(0),
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
sessionId: row.session_id,
|
|
1213
|
+
enabled: row.enabled === null ? null : row.enabled !== 0,
|
|
1214
|
+
updatedAt: new Date(row.updated_at),
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Upsert per-session notification preference override
|
|
1220
|
+
*/
|
|
1221
|
+
setSessionNotificationPrefs(
|
|
1222
|
+
sessionId: string,
|
|
1223
|
+
enabled: boolean | null
|
|
1224
|
+
): SessionNotificationPrefsRecord {
|
|
1225
|
+
const now = Date.now();
|
|
1226
|
+
this.db
|
|
1227
|
+
.prepare(
|
|
1228
|
+
`
|
|
1229
|
+
INSERT INTO session_notification_prefs (session_id, enabled, updated_at)
|
|
1230
|
+
VALUES (?, ?, ?)
|
|
1231
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1232
|
+
enabled = excluded.enabled,
|
|
1233
|
+
updated_at = excluded.updated_at
|
|
1234
|
+
`
|
|
1235
|
+
)
|
|
1236
|
+
.run(sessionId, enabled === null ? null : enabled ? 1 : 0, now);
|
|
1237
|
+
|
|
1238
|
+
return {
|
|
1239
|
+
sessionId,
|
|
1240
|
+
enabled,
|
|
1241
|
+
updatedAt: new Date(now),
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
listPushSubscriptions(): PushSubscriptionRecord[] {
|
|
1246
|
+
const rows = this.db
|
|
1247
|
+
.prepare(
|
|
1248
|
+
`
|
|
1249
|
+
SELECT endpoint, p256dh, auth, expiration_time, created_at, updated_at
|
|
1250
|
+
FROM push_subscriptions
|
|
1251
|
+
ORDER BY updated_at DESC
|
|
1252
|
+
`
|
|
1253
|
+
)
|
|
1254
|
+
.all() as any[];
|
|
1255
|
+
|
|
1256
|
+
return rows.map((row) => this.mapRowToPushSubscriptionRecord(row));
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
upsertPushSubscription(params: UpsertPushSubscriptionParams): PushSubscriptionRecord {
|
|
1260
|
+
const now = Date.now();
|
|
1261
|
+
this.db
|
|
1262
|
+
.prepare(
|
|
1263
|
+
`
|
|
1264
|
+
INSERT INTO push_subscriptions (
|
|
1265
|
+
endpoint,
|
|
1266
|
+
p256dh,
|
|
1267
|
+
auth,
|
|
1268
|
+
expiration_time,
|
|
1269
|
+
created_at,
|
|
1270
|
+
updated_at
|
|
1271
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1272
|
+
ON CONFLICT(endpoint) DO UPDATE SET
|
|
1273
|
+
p256dh = excluded.p256dh,
|
|
1274
|
+
auth = excluded.auth,
|
|
1275
|
+
expiration_time = excluded.expiration_time,
|
|
1276
|
+
updated_at = excluded.updated_at
|
|
1277
|
+
`
|
|
1278
|
+
)
|
|
1279
|
+
.run(
|
|
1280
|
+
params.endpoint,
|
|
1281
|
+
params.keys.p256dh,
|
|
1282
|
+
params.keys.auth,
|
|
1283
|
+
params.expirationTime ?? null,
|
|
1284
|
+
now,
|
|
1285
|
+
now
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
const row = this.db
|
|
1289
|
+
.prepare(
|
|
1290
|
+
`
|
|
1291
|
+
SELECT endpoint, p256dh, auth, expiration_time, created_at, updated_at
|
|
1292
|
+
FROM push_subscriptions
|
|
1293
|
+
WHERE endpoint = ?
|
|
1294
|
+
`
|
|
1295
|
+
)
|
|
1296
|
+
.get(params.endpoint) as any;
|
|
1297
|
+
|
|
1298
|
+
return this.mapRowToPushSubscriptionRecord(row);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
deletePushSubscription(endpoint: string): boolean {
|
|
1302
|
+
const result = this.db
|
|
1303
|
+
.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
|
1304
|
+
.run(endpoint);
|
|
1305
|
+
return result.changes > 0;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Get transcript offset for a session and path
|
|
1310
|
+
*/
|
|
1311
|
+
getTranscriptOffset(sessionId: string, path: string): TranscriptOffset {
|
|
1312
|
+
const stmt = this.db.prepare(`
|
|
1313
|
+
SELECT byte_offset, last_line_hash
|
|
1314
|
+
FROM transcript_offsets
|
|
1315
|
+
WHERE session_id = ? AND path = ?
|
|
1316
|
+
`);
|
|
1317
|
+
|
|
1318
|
+
const row = stmt.get(sessionId, path) as any;
|
|
1319
|
+
|
|
1320
|
+
if (!row) {
|
|
1321
|
+
return { byteOffset: 0, lastLineHash: null };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return {
|
|
1325
|
+
byteOffset: row.byte_offset,
|
|
1326
|
+
lastLineHash: row.last_line_hash,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Update transcript offset for a session and path
|
|
1332
|
+
*/
|
|
1333
|
+
updateTranscriptOffset(sessionId: string, path: string, offset: TranscriptOffset): void {
|
|
1334
|
+
const stmt = this.db.prepare(`
|
|
1335
|
+
INSERT INTO transcript_offsets (session_id, path, byte_offset, last_line_hash)
|
|
1336
|
+
VALUES (?, ?, ?, ?)
|
|
1337
|
+
ON CONFLICT(session_id, path) DO UPDATE SET
|
|
1338
|
+
byte_offset = excluded.byte_offset,
|
|
1339
|
+
last_line_hash = excluded.last_line_hash
|
|
1340
|
+
`);
|
|
1341
|
+
|
|
1342
|
+
stmt.run(sessionId, path, offset.byteOffset, offset.lastLineHash);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Map database row to SessionHandle
|
|
1347
|
+
*/
|
|
1348
|
+
private mapRowToSessionHandle(row: any): SessionHandle {
|
|
1349
|
+
return {
|
|
1350
|
+
id: row.id,
|
|
1351
|
+
provider: row.provider as ProviderId,
|
|
1352
|
+
cwd: row.cwd,
|
|
1353
|
+
status: row.status as SessionStatus,
|
|
1354
|
+
createdAt: new Date(row.created_at),
|
|
1355
|
+
updatedAt: new Date(row.updated_at),
|
|
1356
|
+
pid: row.pid ?? undefined,
|
|
1357
|
+
ptyRows: row.pty_rows ?? undefined,
|
|
1358
|
+
ptyCols: row.pty_cols ?? undefined,
|
|
1359
|
+
transcriptPath: row.transcript_path ?? undefined,
|
|
1360
|
+
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Map database row to EventRecord
|
|
1366
|
+
*/
|
|
1367
|
+
private mapRowToEventRecord(row: any): EventRecord {
|
|
1368
|
+
return {
|
|
1369
|
+
id: row.id,
|
|
1370
|
+
sessionId: row.session_id,
|
|
1371
|
+
timestamp: new Date(row.ts),
|
|
1372
|
+
source: row.source as EventSource,
|
|
1373
|
+
type: row.type,
|
|
1374
|
+
payload: JSON.parse(row.payload_json),
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Map database row to SessionNotificationRecord
|
|
1380
|
+
*/
|
|
1381
|
+
private mapRowToSessionNotificationRecord(row: any): SessionNotificationRecord {
|
|
1382
|
+
return {
|
|
1383
|
+
id: row.id,
|
|
1384
|
+
sessionId: row.session_id,
|
|
1385
|
+
provider: row.provider,
|
|
1386
|
+
eventType: row.event_type,
|
|
1387
|
+
sourceEventId: row.source_event_id ?? null,
|
|
1388
|
+
title: row.title,
|
|
1389
|
+
body: row.body ?? null,
|
|
1390
|
+
payload: JSON.parse(row.payload_json),
|
|
1391
|
+
createdAt: new Date(row.created_at),
|
|
1392
|
+
readAt: row.read_at == null ? null : new Date(row.read_at),
|
|
1393
|
+
readSource: row.read_source ?? null,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private mapRowToPushSubscriptionRecord(row: any): PushSubscriptionRecord {
|
|
1398
|
+
return {
|
|
1399
|
+
endpoint: row.endpoint,
|
|
1400
|
+
keys: {
|
|
1401
|
+
p256dh: row.p256dh,
|
|
1402
|
+
auth: row.auth,
|
|
1403
|
+
},
|
|
1404
|
+
expirationTime:
|
|
1405
|
+
row.expiration_time === null || row.expiration_time === undefined
|
|
1406
|
+
? null
|
|
1407
|
+
: Number(row.expiration_time),
|
|
1408
|
+
createdAt: new Date(row.created_at),
|
|
1409
|
+
updatedAt: new Date(row.updated_at),
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Create a new policy
|
|
1415
|
+
*/
|
|
1416
|
+
createPolicy(params: CreatePolicyParams): void {
|
|
1417
|
+
const now = Date.now();
|
|
1418
|
+
const stmt = this.db.prepare(`
|
|
1419
|
+
INSERT INTO policies (
|
|
1420
|
+
id, name, description, enabled, priority, session_id, rules_json, created_at, updated_at
|
|
1421
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1422
|
+
`);
|
|
1423
|
+
|
|
1424
|
+
stmt.run(
|
|
1425
|
+
params.id,
|
|
1426
|
+
params.name,
|
|
1427
|
+
params.description ?? null,
|
|
1428
|
+
(params.enabled ?? true) ? 1 : 0,
|
|
1429
|
+
params.priority ?? 0,
|
|
1430
|
+
params.sessionId ?? null,
|
|
1431
|
+
JSON.stringify(params.rules),
|
|
1432
|
+
now,
|
|
1433
|
+
now
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Get policy by ID
|
|
1439
|
+
*/
|
|
1440
|
+
getPolicy(id: string): PolicyRecord | undefined {
|
|
1441
|
+
const stmt = this.db.prepare("SELECT * FROM policies WHERE id = ?");
|
|
1442
|
+
const row = stmt.get(id) as any;
|
|
1443
|
+
if (!row) return undefined;
|
|
1444
|
+
return this.mapRowToPolicyRecord(row);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Update policy fields
|
|
1449
|
+
*/
|
|
1450
|
+
updatePolicy(id: string, params: UpdatePolicyParams): void {
|
|
1451
|
+
const updates: string[] = [];
|
|
1452
|
+
const values: SQLQueryBindings[] = [];
|
|
1453
|
+
|
|
1454
|
+
if (params.name !== undefined) {
|
|
1455
|
+
updates.push("name = ?");
|
|
1456
|
+
values.push(params.name);
|
|
1457
|
+
}
|
|
1458
|
+
if (params.description !== undefined) {
|
|
1459
|
+
updates.push("description = ?");
|
|
1460
|
+
values.push(params.description);
|
|
1461
|
+
}
|
|
1462
|
+
if (params.enabled !== undefined) {
|
|
1463
|
+
updates.push("enabled = ?");
|
|
1464
|
+
values.push(params.enabled ? 1 : 0);
|
|
1465
|
+
}
|
|
1466
|
+
if (params.priority !== undefined) {
|
|
1467
|
+
updates.push("priority = ?");
|
|
1468
|
+
values.push(params.priority);
|
|
1469
|
+
}
|
|
1470
|
+
if (params.rules !== undefined) {
|
|
1471
|
+
updates.push("rules_json = ?");
|
|
1472
|
+
values.push(JSON.stringify(params.rules));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Always update updated_at
|
|
1476
|
+
updates.push("updated_at = ?");
|
|
1477
|
+
values.push(Date.now());
|
|
1478
|
+
|
|
1479
|
+
if (updates.length === 1) {
|
|
1480
|
+
// Only updated_at changed, still execute
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
values.push(id);
|
|
1484
|
+
|
|
1485
|
+
const sql = `UPDATE policies SET ${updates.join(", ")} WHERE id = ?`;
|
|
1486
|
+
const stmt = this.db.prepare(sql);
|
|
1487
|
+
stmt.run(...values);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Delete policy
|
|
1492
|
+
*/
|
|
1493
|
+
deletePolicy(id: string): void {
|
|
1494
|
+
const stmt = this.db.prepare("DELETE FROM policies WHERE id = ?");
|
|
1495
|
+
stmt.run(id);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* List policies with optional filters
|
|
1500
|
+
*/
|
|
1501
|
+
listPolicies(options: ListPoliciesOptions = {}): PolicyRecord[] {
|
|
1502
|
+
let sql = "SELECT * FROM policies WHERE 1=1";
|
|
1503
|
+
const values: SQLQueryBindings[] = [];
|
|
1504
|
+
|
|
1505
|
+
if (options.sessionId !== undefined) {
|
|
1506
|
+
if (options.sessionId === null) {
|
|
1507
|
+
sql += " AND session_id IS NULL";
|
|
1508
|
+
} else {
|
|
1509
|
+
sql += " AND session_id = ?";
|
|
1510
|
+
values.push(options.sessionId);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
if (options.enabled !== undefined) {
|
|
1514
|
+
sql += " AND enabled = ?";
|
|
1515
|
+
values.push(options.enabled ? 1 : 0);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
sql += " ORDER BY priority DESC, created_at DESC";
|
|
1519
|
+
|
|
1520
|
+
const stmt = this.db.prepare(sql);
|
|
1521
|
+
const rows = stmt.all(...values) as any[];
|
|
1522
|
+
|
|
1523
|
+
return rows.map((row) => this.mapRowToPolicyRecord(row));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Insert a policy decision (audit log)
|
|
1528
|
+
*/
|
|
1529
|
+
insertPolicyDecision(params: InsertPolicyDecisionParams): number {
|
|
1530
|
+
const timestamp = params.timestamp ?? new Date();
|
|
1531
|
+
const stmt = this.db.prepare(`
|
|
1532
|
+
INSERT INTO policy_decisions (
|
|
1533
|
+
session_id, event_id, policy_id, tool_name, args_json, decision, reason, timestamp
|
|
1534
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1535
|
+
`);
|
|
1536
|
+
|
|
1537
|
+
stmt.run(
|
|
1538
|
+
params.sessionId,
|
|
1539
|
+
params.eventId ?? null,
|
|
1540
|
+
params.policyId ?? null,
|
|
1541
|
+
params.toolName,
|
|
1542
|
+
params.args ? JSON.stringify(params.args) : null,
|
|
1543
|
+
params.decision,
|
|
1544
|
+
params.reason ?? null,
|
|
1545
|
+
timestamp.getTime()
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
// Get last inserted row id
|
|
1549
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
1550
|
+
return result.id;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Get policy decisions for a session
|
|
1555
|
+
*/
|
|
1556
|
+
getPolicyDecisionsBySessionId(
|
|
1557
|
+
sessionId: string,
|
|
1558
|
+
options: GetPolicyDecisionsOptions = {}
|
|
1559
|
+
): PolicyDecisionRecord[] {
|
|
1560
|
+
let sql = "SELECT * FROM policy_decisions WHERE session_id = ?";
|
|
1561
|
+
const values: SQLQueryBindings[] = [sessionId];
|
|
1562
|
+
|
|
1563
|
+
if (options.since !== undefined) {
|
|
1564
|
+
sql += " AND id > ?";
|
|
1565
|
+
values.push(options.since);
|
|
1566
|
+
}
|
|
1567
|
+
if (options.decision) {
|
|
1568
|
+
sql += " AND decision = ?";
|
|
1569
|
+
values.push(options.decision);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
sql += " ORDER BY id ASC";
|
|
1573
|
+
|
|
1574
|
+
if (options.limit) {
|
|
1575
|
+
sql += " LIMIT ?";
|
|
1576
|
+
values.push(options.limit);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const stmt = this.db.prepare(sql);
|
|
1580
|
+
const rows = stmt.all(...values) as any[];
|
|
1581
|
+
|
|
1582
|
+
return rows.map((row) => this.mapRowToPolicyDecisionRecord(row));
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Map database row to PolicyRecord
|
|
1587
|
+
*/
|
|
1588
|
+
private mapRowToPolicyRecord(row: any): PolicyRecord {
|
|
1589
|
+
return {
|
|
1590
|
+
id: row.id,
|
|
1591
|
+
name: row.name,
|
|
1592
|
+
description: row.description ?? undefined,
|
|
1593
|
+
enabled: row.enabled === 1,
|
|
1594
|
+
priority: row.priority,
|
|
1595
|
+
sessionId: row.session_id ?? undefined,
|
|
1596
|
+
rules: JSON.parse(row.rules_json),
|
|
1597
|
+
createdAt: new Date(row.created_at),
|
|
1598
|
+
updatedAt: new Date(row.updated_at),
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Map database row to PolicyDecisionRecord
|
|
1604
|
+
*/
|
|
1605
|
+
private mapRowToPolicyDecisionRecord(row: any): PolicyDecisionRecord {
|
|
1606
|
+
return {
|
|
1607
|
+
id: row.id,
|
|
1608
|
+
sessionId: row.session_id,
|
|
1609
|
+
eventId: row.event_id ?? undefined,
|
|
1610
|
+
policyId: row.policy_id ?? undefined,
|
|
1611
|
+
toolName: row.tool_name,
|
|
1612
|
+
args: row.args_json ? JSON.parse(row.args_json) : undefined,
|
|
1613
|
+
decision: row.decision,
|
|
1614
|
+
reason: row.reason ?? undefined,
|
|
1615
|
+
timestamp: new Date(row.timestamp),
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// ─── Policy Set Operations ─────────────────────────────────────────
|
|
1620
|
+
|
|
1621
|
+
createPolicySet(params: CreatePolicySetParams): void {
|
|
1622
|
+
const now = Date.now();
|
|
1623
|
+
|
|
1624
|
+
// If setting as default, clear any existing default first
|
|
1625
|
+
if (params.isDefault) {
|
|
1626
|
+
this.db.prepare("UPDATE policy_sets SET is_default = 0 WHERE is_default = 1").run();
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
this.db
|
|
1630
|
+
.prepare(
|
|
1631
|
+
`INSERT INTO policy_sets (id, name, description, is_default, created_at, updated_at)
|
|
1632
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
1633
|
+
)
|
|
1634
|
+
.run(params.id, params.name, params.description ?? null, params.isDefault ? 1 : 0, now, now);
|
|
1635
|
+
|
|
1636
|
+
// Add initial member policies
|
|
1637
|
+
if (params.policyIds?.length) {
|
|
1638
|
+
const addStmt = this.db.prepare(
|
|
1639
|
+
`INSERT OR IGNORE INTO policy_set_members (policy_set_id, policy_id, added_at)
|
|
1640
|
+
VALUES (?, ?, ?)`
|
|
1641
|
+
);
|
|
1642
|
+
for (const policyId of params.policyIds) {
|
|
1643
|
+
addStmt.run(params.id, policyId, now);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
getPolicySet(id: string): PolicySetRecord | undefined {
|
|
1649
|
+
const row = this.db.prepare("SELECT * FROM policy_sets WHERE id = ?").get(id) as any;
|
|
1650
|
+
if (!row) return undefined;
|
|
1651
|
+
return this.mapRowToPolicySetRecord(row);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
updatePolicySet(id: string, params: UpdatePolicySetParams): void {
|
|
1655
|
+
const updates: string[] = [];
|
|
1656
|
+
const values: SQLQueryBindings[] = [];
|
|
1657
|
+
|
|
1658
|
+
if (params.name !== undefined) {
|
|
1659
|
+
updates.push("name = ?");
|
|
1660
|
+
values.push(params.name);
|
|
1661
|
+
}
|
|
1662
|
+
if (params.description !== undefined) {
|
|
1663
|
+
updates.push("description = ?");
|
|
1664
|
+
values.push(params.description);
|
|
1665
|
+
}
|
|
1666
|
+
if (params.isDefault !== undefined) {
|
|
1667
|
+
// Clear existing default if setting this one
|
|
1668
|
+
if (params.isDefault) {
|
|
1669
|
+
this.db.prepare("UPDATE policy_sets SET is_default = 0 WHERE is_default = 1").run();
|
|
1670
|
+
}
|
|
1671
|
+
updates.push("is_default = ?");
|
|
1672
|
+
values.push(params.isDefault ? 1 : 0);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
updates.push("updated_at = ?");
|
|
1676
|
+
values.push(Date.now());
|
|
1677
|
+
values.push(id);
|
|
1678
|
+
|
|
1679
|
+
this.db.prepare(`UPDATE policy_sets SET ${updates.join(", ")} WHERE id = ?`).run(...values);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
deletePolicySet(id: string): void {
|
|
1683
|
+
this.db.prepare("DELETE FROM policy_sets WHERE id = ?").run(id);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
listPolicySets(): PolicySetSummary[] {
|
|
1687
|
+
const rows = this.db
|
|
1688
|
+
.prepare(
|
|
1689
|
+
`SELECT ps.*,
|
|
1690
|
+
COUNT(DISTINCT psm.policy_id) as policy_count,
|
|
1691
|
+
COUNT(DISTINCT sps.session_id) as session_count
|
|
1692
|
+
FROM policy_sets ps
|
|
1693
|
+
LEFT JOIN policy_set_members psm ON ps.id = psm.policy_set_id
|
|
1694
|
+
LEFT JOIN session_policy_sets sps ON ps.id = sps.policy_set_id
|
|
1695
|
+
GROUP BY ps.id
|
|
1696
|
+
ORDER BY ps.name`
|
|
1697
|
+
)
|
|
1698
|
+
.all() as any[];
|
|
1699
|
+
|
|
1700
|
+
return rows.map((row) => ({
|
|
1701
|
+
id: row.id,
|
|
1702
|
+
name: row.name,
|
|
1703
|
+
description: row.description ?? undefined,
|
|
1704
|
+
isDefault: row.is_default === 1,
|
|
1705
|
+
policyCount: row.policy_count,
|
|
1706
|
+
sessionCount: row.session_count,
|
|
1707
|
+
createdAt: new Date(row.created_at),
|
|
1708
|
+
updatedAt: new Date(row.updated_at),
|
|
1709
|
+
}));
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
addPolicyToSet(setId: string, policyId: string): void {
|
|
1713
|
+
this.db
|
|
1714
|
+
.prepare(
|
|
1715
|
+
`INSERT OR IGNORE INTO policy_set_members (policy_set_id, policy_id, added_at)
|
|
1716
|
+
VALUES (?, ?, ?)`
|
|
1717
|
+
)
|
|
1718
|
+
.run(setId, policyId, Date.now());
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
removePolicyFromSet(setId: string, policyId: string): void {
|
|
1722
|
+
this.db
|
|
1723
|
+
.prepare("DELETE FROM policy_set_members WHERE policy_set_id = ? AND policy_id = ?")
|
|
1724
|
+
.run(setId, policyId);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
getPolicySetMembers(setId: string): PolicyRecord[] {
|
|
1728
|
+
const rows = this.db
|
|
1729
|
+
.prepare(
|
|
1730
|
+
`SELECT p.* FROM policies p
|
|
1731
|
+
JOIN policy_set_members psm ON p.id = psm.policy_id
|
|
1732
|
+
WHERE psm.policy_set_id = ?
|
|
1733
|
+
ORDER BY p.priority DESC, p.name`
|
|
1734
|
+
)
|
|
1735
|
+
.all(setId) as any[];
|
|
1736
|
+
|
|
1737
|
+
return rows.map((row) => this.mapRowToPolicyRecord(row));
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
applyPolicySetToSession(sessionId: string, setId: string): void {
|
|
1741
|
+
this.db
|
|
1742
|
+
.prepare(
|
|
1743
|
+
`INSERT OR IGNORE INTO session_policy_sets (session_id, policy_set_id, applied_at)
|
|
1744
|
+
VALUES (?, ?, ?)`
|
|
1745
|
+
)
|
|
1746
|
+
.run(sessionId, setId, Date.now());
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
removePolicySetFromSession(sessionId: string, setId: string): void {
|
|
1750
|
+
this.db
|
|
1751
|
+
.prepare("DELETE FROM session_policy_sets WHERE session_id = ? AND policy_set_id = ?")
|
|
1752
|
+
.run(sessionId, setId);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
getSessionPolicySets(sessionId: string): PolicySetSummary[] {
|
|
1756
|
+
const rows = this.db
|
|
1757
|
+
.prepare(
|
|
1758
|
+
`SELECT ps.*,
|
|
1759
|
+
COUNT(DISTINCT psm.policy_id) as policy_count,
|
|
1760
|
+
(SELECT COUNT(*) FROM session_policy_sets WHERE policy_set_id = ps.id) as session_count
|
|
1761
|
+
FROM policy_sets ps
|
|
1762
|
+
JOIN session_policy_sets sps ON ps.id = sps.policy_set_id
|
|
1763
|
+
LEFT JOIN policy_set_members psm ON ps.id = psm.policy_set_id
|
|
1764
|
+
WHERE sps.session_id = ?
|
|
1765
|
+
GROUP BY ps.id
|
|
1766
|
+
ORDER BY ps.name`
|
|
1767
|
+
)
|
|
1768
|
+
.all(sessionId) as any[];
|
|
1769
|
+
|
|
1770
|
+
return rows.map((row) => ({
|
|
1771
|
+
id: row.id,
|
|
1772
|
+
name: row.name,
|
|
1773
|
+
description: row.description ?? undefined,
|
|
1774
|
+
isDefault: row.is_default === 1,
|
|
1775
|
+
policyCount: row.policy_count,
|
|
1776
|
+
sessionCount: row.session_count,
|
|
1777
|
+
createdAt: new Date(row.created_at),
|
|
1778
|
+
updatedAt: new Date(row.updated_at),
|
|
1779
|
+
}));
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
getEffectivePolicies(sessionId: string): PolicyRecord[] {
|
|
1783
|
+
// 1. Direct per-session policies
|
|
1784
|
+
const directPolicies = this.listPolicies({ sessionId, enabled: true });
|
|
1785
|
+
|
|
1786
|
+
// 2. Policies from applied policy sets
|
|
1787
|
+
const setPolicyRows = this.db
|
|
1788
|
+
.prepare(
|
|
1789
|
+
`SELECT DISTINCT p.* FROM policies p
|
|
1790
|
+
JOIN policy_set_members psm ON p.id = psm.policy_id
|
|
1791
|
+
JOIN session_policy_sets sps ON psm.policy_set_id = sps.policy_set_id
|
|
1792
|
+
WHERE sps.session_id = ? AND p.enabled = 1`
|
|
1793
|
+
)
|
|
1794
|
+
.all(sessionId) as any[];
|
|
1795
|
+
const setPolicies = setPolicyRows.map((row) => this.mapRowToPolicyRecord(row));
|
|
1796
|
+
|
|
1797
|
+
// 3. Global policies (session_id IS NULL)
|
|
1798
|
+
const globalPolicies = this.listPolicies({ sessionId: null as any, enabled: true });
|
|
1799
|
+
|
|
1800
|
+
// Deduplicate: direct > set > global (order determines precedence for same ID)
|
|
1801
|
+
const seen = new Set<string>();
|
|
1802
|
+
const all: PolicyRecord[] = [];
|
|
1803
|
+
for (const policy of [...directPolicies, ...setPolicies, ...globalPolicies]) {
|
|
1804
|
+
if (!seen.has(policy.id)) {
|
|
1805
|
+
seen.add(policy.id);
|
|
1806
|
+
all.push(policy);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Sort by priority DESC (PolicyEngine also sorts, but this keeps the output consistent)
|
|
1811
|
+
all.sort((a, b) => b.priority - a.priority);
|
|
1812
|
+
return all;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
getDefaultPolicySet(): PolicySetRecord | undefined {
|
|
1816
|
+
const row = this.db.prepare("SELECT * FROM policy_sets WHERE is_default = 1").get() as any;
|
|
1817
|
+
if (!row) return undefined;
|
|
1818
|
+
return this.mapRowToPolicySetRecord(row);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
private mapRowToPolicySetRecord(row: any): PolicySetRecord {
|
|
1822
|
+
return {
|
|
1823
|
+
id: row.id,
|
|
1824
|
+
name: row.name,
|
|
1825
|
+
description: row.description ?? undefined,
|
|
1826
|
+
isDefault: row.is_default === 1,
|
|
1827
|
+
createdAt: new Date(row.created_at),
|
|
1828
|
+
updatedAt: new Date(row.updated_at),
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Insert token usage record
|
|
1834
|
+
*/
|
|
1835
|
+
insertTokenUsage(params: InsertTokenUsageParams): number {
|
|
1836
|
+
const timestamp = params.timestamp ?? new Date();
|
|
1837
|
+
const stmt = this.db.prepare(`
|
|
1838
|
+
INSERT INTO token_usage (
|
|
1839
|
+
session_id, event_id, timestamp, model,
|
|
1840
|
+
prompt_tokens, completion_tokens,
|
|
1841
|
+
cache_creation_input_tokens, cache_read_input_tokens,
|
|
1842
|
+
total_tokens, estimated_cost_usd, actual_cost_usd, cost_difference_usd
|
|
1843
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1844
|
+
`);
|
|
1845
|
+
|
|
1846
|
+
stmt.run(
|
|
1847
|
+
params.sessionId,
|
|
1848
|
+
params.eventId ?? null,
|
|
1849
|
+
timestamp.getTime(),
|
|
1850
|
+
params.model,
|
|
1851
|
+
params.promptTokens,
|
|
1852
|
+
params.completionTokens,
|
|
1853
|
+
params.cacheCreationInputTokens ?? 0,
|
|
1854
|
+
params.cacheReadInputTokens ?? 0,
|
|
1855
|
+
params.totalTokens,
|
|
1856
|
+
params.estimatedCostUsd ?? null,
|
|
1857
|
+
params.actualCostUsd ?? null,
|
|
1858
|
+
params.costDifferenceUsd ?? null
|
|
1859
|
+
);
|
|
1860
|
+
|
|
1861
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
1862
|
+
return result.id;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Get token usage records for a session
|
|
1867
|
+
*/
|
|
1868
|
+
getTokenUsageBySessionId(
|
|
1869
|
+
sessionId: string,
|
|
1870
|
+
options: GetTokenUsageOptions = {}
|
|
1871
|
+
): TokenUsageRecord[] {
|
|
1872
|
+
let sql = "SELECT * FROM token_usage WHERE session_id = ?";
|
|
1873
|
+
const values: SQLQueryBindings[] = [sessionId];
|
|
1874
|
+
|
|
1875
|
+
if (options.since) {
|
|
1876
|
+
sql += " AND timestamp >= ?";
|
|
1877
|
+
values.push(options.since.getTime());
|
|
1878
|
+
}
|
|
1879
|
+
if (options.until) {
|
|
1880
|
+
sql += " AND timestamp <= ?";
|
|
1881
|
+
values.push(options.until.getTime());
|
|
1882
|
+
}
|
|
1883
|
+
if (options.model) {
|
|
1884
|
+
sql += " AND model = ?";
|
|
1885
|
+
values.push(options.model);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
sql += " ORDER BY timestamp ASC";
|
|
1889
|
+
|
|
1890
|
+
if (options.limit) {
|
|
1891
|
+
sql += " LIMIT ?";
|
|
1892
|
+
values.push(options.limit);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const stmt = this.db.prepare(sql);
|
|
1896
|
+
const rows = stmt.all(...values) as any[];
|
|
1897
|
+
|
|
1898
|
+
return rows.map((row) => this.mapRowToTokenUsageRecord(row));
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Get total tokens and cost by session
|
|
1903
|
+
*/
|
|
1904
|
+
getTotalTokensBySessionId(sessionId: string): {
|
|
1905
|
+
totalTokens: number;
|
|
1906
|
+
totalCost: number;
|
|
1907
|
+
byModel: Record<string, { tokens: number; cost: number }>;
|
|
1908
|
+
} {
|
|
1909
|
+
const stmt = this.db.prepare(`
|
|
1910
|
+
SELECT
|
|
1911
|
+
model,
|
|
1912
|
+
SUM(total_tokens) as total_tokens,
|
|
1913
|
+
SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)) as total_cost
|
|
1914
|
+
FROM token_usage
|
|
1915
|
+
WHERE session_id = ?
|
|
1916
|
+
GROUP BY model
|
|
1917
|
+
`);
|
|
1918
|
+
|
|
1919
|
+
const rows = stmt.all(sessionId) as any[];
|
|
1920
|
+
|
|
1921
|
+
const byModel: Record<string, { tokens: number; cost: number }> = {};
|
|
1922
|
+
let totalTokens = 0;
|
|
1923
|
+
let totalCost = 0;
|
|
1924
|
+
|
|
1925
|
+
for (const row of rows) {
|
|
1926
|
+
const tokens = row.total_tokens || 0;
|
|
1927
|
+
const cost = row.total_cost || 0;
|
|
1928
|
+
|
|
1929
|
+
byModel[row.model] = { tokens, cost };
|
|
1930
|
+
totalTokens += tokens;
|
|
1931
|
+
totalCost += cost;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return { totalTokens, totalCost, byModel };
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Insert model switch record
|
|
1939
|
+
*/
|
|
1940
|
+
insertModelSwitch(params: InsertModelSwitchParams): number {
|
|
1941
|
+
const timestamp = params.timestamp ?? new Date();
|
|
1942
|
+
const stmt = this.db.prepare(`
|
|
1943
|
+
INSERT INTO model_switches (
|
|
1944
|
+
session_id, timestamp, from_model, to_model, reason
|
|
1945
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1946
|
+
`);
|
|
1947
|
+
|
|
1948
|
+
stmt.run(
|
|
1949
|
+
params.sessionId,
|
|
1950
|
+
timestamp.getTime(),
|
|
1951
|
+
params.fromModel ?? null,
|
|
1952
|
+
params.toModel,
|
|
1953
|
+
params.reason ?? null
|
|
1954
|
+
);
|
|
1955
|
+
|
|
1956
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
1957
|
+
return result.id;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Get model switches for a session
|
|
1962
|
+
*/
|
|
1963
|
+
getModelSwitchesBySessionId(sessionId: string): ModelSwitchRecord[] {
|
|
1964
|
+
const stmt = this.db.prepare(`
|
|
1965
|
+
SELECT * FROM model_switches
|
|
1966
|
+
WHERE session_id = ?
|
|
1967
|
+
ORDER BY timestamp ASC
|
|
1968
|
+
`);
|
|
1969
|
+
|
|
1970
|
+
const rows = stmt.all(sessionId) as any[];
|
|
1971
|
+
return rows.map((row) => this.mapRowToModelSwitchRecord(row));
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Insert transcript content record
|
|
1976
|
+
*/
|
|
1977
|
+
insertTranscriptContent(params: InsertTranscriptContentParams): number {
|
|
1978
|
+
const timestamp = params.timestamp ?? new Date();
|
|
1979
|
+
const stmt = this.db.prepare(`
|
|
1980
|
+
INSERT INTO transcript_content (
|
|
1981
|
+
session_id, event_id, role, content, timestamp
|
|
1982
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1983
|
+
`);
|
|
1984
|
+
|
|
1985
|
+
stmt.run(
|
|
1986
|
+
params.sessionId,
|
|
1987
|
+
params.eventId ?? null,
|
|
1988
|
+
params.role,
|
|
1989
|
+
params.content,
|
|
1990
|
+
timestamp.getTime()
|
|
1991
|
+
);
|
|
1992
|
+
|
|
1993
|
+
const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
1994
|
+
return result.id;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Get transcript content for a session
|
|
1999
|
+
*/
|
|
2000
|
+
getTranscriptContentBySessionId(
|
|
2001
|
+
sessionId: string,
|
|
2002
|
+
options: GetTranscriptContentOptions = {}
|
|
2003
|
+
): TranscriptContentRecord[] {
|
|
2004
|
+
let sql = "SELECT * FROM transcript_content WHERE session_id = ?";
|
|
2005
|
+
const values: SQLQueryBindings[] = [sessionId];
|
|
2006
|
+
|
|
2007
|
+
if (options.since) {
|
|
2008
|
+
sql += " AND timestamp >= ?";
|
|
2009
|
+
values.push(options.since.getTime());
|
|
2010
|
+
}
|
|
2011
|
+
if (options.until) {
|
|
2012
|
+
sql += " AND timestamp <= ?";
|
|
2013
|
+
values.push(options.until.getTime());
|
|
2014
|
+
}
|
|
2015
|
+
if (options.role) {
|
|
2016
|
+
sql += " AND role = ?";
|
|
2017
|
+
values.push(options.role);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
sql += " ORDER BY timestamp ASC";
|
|
2021
|
+
|
|
2022
|
+
if (options.limit) {
|
|
2023
|
+
sql += " LIMIT ?";
|
|
2024
|
+
values.push(options.limit);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const stmt = this.db.prepare(sql);
|
|
2028
|
+
const rows = stmt.all(...values) as any[];
|
|
2029
|
+
|
|
2030
|
+
return rows.map((row) => this.mapRowToTranscriptContentRecord(row));
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Map database row to TokenUsageRecord
|
|
2035
|
+
*/
|
|
2036
|
+
private mapRowToTokenUsageRecord(row: any): TokenUsageRecord {
|
|
2037
|
+
return {
|
|
2038
|
+
id: row.id,
|
|
2039
|
+
sessionId: row.session_id,
|
|
2040
|
+
eventId: row.event_id ?? undefined,
|
|
2041
|
+
timestamp: new Date(row.timestamp),
|
|
2042
|
+
model: row.model,
|
|
2043
|
+
promptTokens: row.prompt_tokens,
|
|
2044
|
+
completionTokens: row.completion_tokens,
|
|
2045
|
+
cacheCreationInputTokens: row.cache_creation_input_tokens,
|
|
2046
|
+
cacheReadInputTokens: row.cache_read_input_tokens,
|
|
2047
|
+
totalTokens: row.total_tokens,
|
|
2048
|
+
estimatedCostUsd: row.estimated_cost_usd ?? undefined,
|
|
2049
|
+
actualCostUsd: row.actual_cost_usd ?? undefined,
|
|
2050
|
+
costDifferenceUsd: row.cost_difference_usd ?? undefined,
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Map database row to ModelSwitchRecord
|
|
2056
|
+
*/
|
|
2057
|
+
private mapRowToModelSwitchRecord(row: any): ModelSwitchRecord {
|
|
2058
|
+
return {
|
|
2059
|
+
id: row.id,
|
|
2060
|
+
sessionId: row.session_id,
|
|
2061
|
+
timestamp: new Date(row.timestamp),
|
|
2062
|
+
fromModel: row.from_model ?? undefined,
|
|
2063
|
+
toModel: row.to_model,
|
|
2064
|
+
reason: row.reason ?? undefined,
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Map database row to TranscriptContentRecord
|
|
2070
|
+
*/
|
|
2071
|
+
private mapRowToTranscriptContentRecord(row: any): TranscriptContentRecord {
|
|
2072
|
+
return {
|
|
2073
|
+
id: row.id,
|
|
2074
|
+
sessionId: row.session_id,
|
|
2075
|
+
eventId: row.event_id ?? undefined,
|
|
2076
|
+
role: row.role as "user" | "assistant" | "system",
|
|
2077
|
+
content: row.content,
|
|
2078
|
+
timestamp: new Date(row.timestamp),
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// ─── Workspace Operations ──────────────────────────────────────────
|
|
2083
|
+
|
|
2084
|
+
createWorkspace(params: CreateWorkspaceParams): void {
|
|
2085
|
+
const now = Date.now();
|
|
2086
|
+
this.db
|
|
2087
|
+
.prepare(
|
|
2088
|
+
`INSERT INTO workspaces (id, name, path, created_at, updated_at)
|
|
2089
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
2090
|
+
)
|
|
2091
|
+
.run(params.id, params.name, params.path, now, now);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
getWorkspace(id: string): WorkspaceRecord | undefined {
|
|
2095
|
+
const row = this.db.prepare("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
|
|
2096
|
+
if (!row) return undefined;
|
|
2097
|
+
return this.mapRowToWorkspaceRecord(row);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
updateWorkspace(id: string, params: UpdateWorkspaceParams): void {
|
|
2101
|
+
const updates: string[] = [];
|
|
2102
|
+
const values: SQLQueryBindings[] = [];
|
|
2103
|
+
|
|
2104
|
+
if (params.name !== undefined) {
|
|
2105
|
+
updates.push("name = ?");
|
|
2106
|
+
values.push(params.name);
|
|
2107
|
+
}
|
|
2108
|
+
if (params.path !== undefined) {
|
|
2109
|
+
updates.push("path = ?");
|
|
2110
|
+
values.push(params.path);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
updates.push("updated_at = ?");
|
|
2114
|
+
values.push(Date.now());
|
|
2115
|
+
values.push(id);
|
|
2116
|
+
|
|
2117
|
+
this.db.prepare(`UPDATE workspaces SET ${updates.join(", ")} WHERE id = ?`).run(...values);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
deleteWorkspace(id: string): void {
|
|
2121
|
+
this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
listWorkspaces(): WorkspaceRecord[] {
|
|
2125
|
+
const rows = this.db.prepare("SELECT * FROM workspaces ORDER BY name ASC").all() as any[];
|
|
2126
|
+
return rows.map((row) => this.mapRowToWorkspaceRecord(row));
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
private mapRowToWorkspaceRecord(row: any): WorkspaceRecord {
|
|
2130
|
+
return {
|
|
2131
|
+
id: row.id,
|
|
2132
|
+
name: row.name,
|
|
2133
|
+
path: row.path,
|
|
2134
|
+
createdAt: new Date(row.created_at),
|
|
2135
|
+
updatedAt: new Date(row.updated_at),
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// ─── Env Set Operations ────────────────────────────────────────────
|
|
2140
|
+
|
|
2141
|
+
private encryptionKey: Buffer | null = null;
|
|
2142
|
+
|
|
2143
|
+
private getEncryptionKey(): Buffer {
|
|
2144
|
+
if (this.encryptionKey) return this.encryptionKey;
|
|
2145
|
+
// Lazy-load encryption module
|
|
2146
|
+
const { getOrCreateEncryptionKey } = require("../crypto/encryption");
|
|
2147
|
+
this.encryptionKey = getOrCreateEncryptionKey();
|
|
2148
|
+
// biome-ignore lint/style/noNonNullAssertion: assigned on the line above
|
|
2149
|
+
return this.encryptionKey!;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
createEnvSet(params: CreateEnvSetParams): void {
|
|
2153
|
+
const now = Date.now();
|
|
2154
|
+
const { encryptVars } = require("../crypto/encryption");
|
|
2155
|
+
const key = this.getEncryptionKey();
|
|
2156
|
+
const encryptedJson = encryptVars(params.vars, key);
|
|
2157
|
+
|
|
2158
|
+
this.db
|
|
2159
|
+
.prepare(
|
|
2160
|
+
`INSERT INTO env_sets (id, name, description, encrypted_vars_json, created_at, updated_at)
|
|
2161
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2162
|
+
)
|
|
2163
|
+
.run(params.id, params.name, params.description ?? null, encryptedJson, now, now);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
getEnvSet(id: string): EnvSetRecord | undefined {
|
|
2167
|
+
const row = this.db.prepare("SELECT * FROM env_sets WHERE id = ?").get(id) as any;
|
|
2168
|
+
if (!row) return undefined;
|
|
2169
|
+
return this.mapRowToEnvSetRecord(row);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
updateEnvSet(id: string, params: UpdateEnvSetParams): void {
|
|
2173
|
+
const updates: string[] = [];
|
|
2174
|
+
const values: SQLQueryBindings[] = [];
|
|
2175
|
+
|
|
2176
|
+
if (params.name !== undefined) {
|
|
2177
|
+
updates.push("name = ?");
|
|
2178
|
+
values.push(params.name);
|
|
2179
|
+
}
|
|
2180
|
+
if (params.description !== undefined) {
|
|
2181
|
+
updates.push("description = ?");
|
|
2182
|
+
values.push(params.description);
|
|
2183
|
+
}
|
|
2184
|
+
if (params.vars !== undefined) {
|
|
2185
|
+
const { encryptVars } = require("../crypto/encryption");
|
|
2186
|
+
const key = this.getEncryptionKey();
|
|
2187
|
+
updates.push("encrypted_vars_json = ?");
|
|
2188
|
+
values.push(encryptVars(params.vars, key));
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
updates.push("updated_at = ?");
|
|
2192
|
+
values.push(Date.now());
|
|
2193
|
+
values.push(id);
|
|
2194
|
+
|
|
2195
|
+
this.db.prepare(`UPDATE env_sets SET ${updates.join(", ")} WHERE id = ?`).run(...values);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
deleteEnvSet(id: string): void {
|
|
2199
|
+
this.db.prepare("DELETE FROM env_sets WHERE id = ?").run(id);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
listEnvSets(): EnvSetRecord[] {
|
|
2203
|
+
const rows = this.db.prepare("SELECT * FROM env_sets ORDER BY name ASC").all() as any[];
|
|
2204
|
+
return rows.map((row) => this.mapRowToEnvSetRecord(row));
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
decryptEnvSetVars(id: string): Record<string, string> {
|
|
2208
|
+
const row = this.db
|
|
2209
|
+
.prepare("SELECT encrypted_vars_json FROM env_sets WHERE id = ?")
|
|
2210
|
+
.get(id) as any;
|
|
2211
|
+
if (!row) throw new Error(`Env set not found: ${id}`);
|
|
2212
|
+
|
|
2213
|
+
const { decryptVars } = require("../crypto/encryption");
|
|
2214
|
+
const key = this.getEncryptionKey();
|
|
2215
|
+
return decryptVars(row.encrypted_vars_json, key);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// ─── Auth Operations ───────────────────────────────────────────────
|
|
2219
|
+
|
|
2220
|
+
hasAuthConfig(): boolean {
|
|
2221
|
+
const row = this.db.prepare("SELECT COUNT(*) as count FROM auth_config").get() as any;
|
|
2222
|
+
return row.count > 0;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
getAuthConfig(): AuthConfigRecord | null {
|
|
2226
|
+
const row = this.db.prepare("SELECT * FROM auth_config WHERE id = 1").get() as any;
|
|
2227
|
+
if (!row) return null;
|
|
2228
|
+
return {
|
|
2229
|
+
passwordHash: row.password_hash,
|
|
2230
|
+
totpSecretEncrypted: row.totp_secret_encrypted ?? null,
|
|
2231
|
+
totpEnabled: row.totp_enabled === 1,
|
|
2232
|
+
mfaSetupPending: row.mfa_setup_pending === 1,
|
|
2233
|
+
onboardingTokenHash: row.onboarding_token_hash ?? null,
|
|
2234
|
+
onboardingTokenExpiresAt:
|
|
2235
|
+
typeof row.onboarding_token_expires_at === "number"
|
|
2236
|
+
? row.onboarding_token_expires_at
|
|
2237
|
+
: null,
|
|
2238
|
+
recoveryCodesEncrypted: row.recovery_codes_encrypted ?? null,
|
|
2239
|
+
createdAt: new Date(row.created_at),
|
|
2240
|
+
updatedAt: new Date(row.updated_at),
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
createAuthConfig(
|
|
2245
|
+
passwordHash: string,
|
|
2246
|
+
options: {
|
|
2247
|
+
mfaSetupPending?: boolean;
|
|
2248
|
+
onboardingTokenHash?: string | null;
|
|
2249
|
+
onboardingTokenExpiresAt?: number | null;
|
|
2250
|
+
} = {}
|
|
2251
|
+
): void {
|
|
2252
|
+
const now = Date.now();
|
|
2253
|
+
const mfaSetupPending = options.mfaSetupPending ?? false;
|
|
2254
|
+
const onboardingTokenHash = options.onboardingTokenHash ?? null;
|
|
2255
|
+
const onboardingTokenExpiresAt = options.onboardingTokenExpiresAt ?? null;
|
|
2256
|
+
this.db
|
|
2257
|
+
.prepare(
|
|
2258
|
+
`INSERT INTO auth_config (
|
|
2259
|
+
id,
|
|
2260
|
+
password_hash,
|
|
2261
|
+
mfa_setup_pending,
|
|
2262
|
+
onboarding_token_hash,
|
|
2263
|
+
onboarding_token_expires_at,
|
|
2264
|
+
created_at,
|
|
2265
|
+
updated_at
|
|
2266
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?)`
|
|
2267
|
+
)
|
|
2268
|
+
.run(
|
|
2269
|
+
passwordHash,
|
|
2270
|
+
mfaSetupPending ? 1 : 0,
|
|
2271
|
+
onboardingTokenHash,
|
|
2272
|
+
onboardingTokenExpiresAt,
|
|
2273
|
+
now,
|
|
2274
|
+
now
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
updateAuthPassword(passwordHash: string): void {
|
|
2279
|
+
this.db
|
|
2280
|
+
.prepare("UPDATE auth_config SET password_hash = ?, updated_at = ? WHERE id = 1")
|
|
2281
|
+
.run(passwordHash, Date.now());
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
updateAuthTotp(
|
|
2285
|
+
encryptedSecret: string | null,
|
|
2286
|
+
enabled: boolean,
|
|
2287
|
+
recoveryCodes: string | null
|
|
2288
|
+
): void {
|
|
2289
|
+
this.db
|
|
2290
|
+
.prepare(
|
|
2291
|
+
`UPDATE auth_config SET
|
|
2292
|
+
totp_secret_encrypted = ?,
|
|
2293
|
+
totp_enabled = ?,
|
|
2294
|
+
recovery_codes_encrypted = ?,
|
|
2295
|
+
updated_at = ?
|
|
2296
|
+
WHERE id = 1`
|
|
2297
|
+
)
|
|
2298
|
+
.run(encryptedSecret, enabled ? 1 : 0, recoveryCodes, Date.now());
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
updateAuthOnboardingState(
|
|
2302
|
+
mfaSetupPending: boolean,
|
|
2303
|
+
onboardingTokenHash: string | null,
|
|
2304
|
+
onboardingTokenExpiresAt: number | null
|
|
2305
|
+
): void {
|
|
2306
|
+
this.db
|
|
2307
|
+
.prepare(
|
|
2308
|
+
`UPDATE auth_config SET
|
|
2309
|
+
mfa_setup_pending = ?,
|
|
2310
|
+
onboarding_token_hash = ?,
|
|
2311
|
+
onboarding_token_expires_at = ?,
|
|
2312
|
+
updated_at = ?
|
|
2313
|
+
WHERE id = 1`
|
|
2314
|
+
)
|
|
2315
|
+
.run(mfaSetupPending ? 1 : 0, onboardingTokenHash, onboardingTokenExpiresAt, Date.now());
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
createAuthSession(
|
|
2319
|
+
tokenHash: string,
|
|
2320
|
+
ip: string | null,
|
|
2321
|
+
userAgent: string | null,
|
|
2322
|
+
expiresAt: number
|
|
2323
|
+
): void {
|
|
2324
|
+
const now = Date.now();
|
|
2325
|
+
this.db
|
|
2326
|
+
.prepare(
|
|
2327
|
+
`INSERT INTO auth_sessions (token_hash, created_at, expires_at, last_used_at, ip_address, user_agent)
|
|
2328
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2329
|
+
)
|
|
2330
|
+
.run(tokenHash, now, expiresAt, now, ip, userAgent);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
getAuthSession(tokenHash: string): AuthSessionRecord | null {
|
|
2334
|
+
const row = this.db
|
|
2335
|
+
.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?")
|
|
2336
|
+
.get(tokenHash) as any;
|
|
2337
|
+
if (!row) return null;
|
|
2338
|
+
return {
|
|
2339
|
+
tokenHash: row.token_hash,
|
|
2340
|
+
createdAt: row.created_at,
|
|
2341
|
+
expiresAt: row.expires_at,
|
|
2342
|
+
lastUsedAt: row.last_used_at,
|
|
2343
|
+
ipAddress: row.ip_address ?? null,
|
|
2344
|
+
userAgent: row.user_agent ?? null,
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
touchAuthSession(tokenHash: string, newExpiresAt: number): void {
|
|
2349
|
+
this.db
|
|
2350
|
+
.prepare("UPDATE auth_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?")
|
|
2351
|
+
.run(Date.now(), newExpiresAt, tokenHash);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
deleteAuthSession(tokenHash: string): void {
|
|
2355
|
+
this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
deleteAllAuthSessions(): void {
|
|
2359
|
+
this.db.prepare("DELETE FROM auth_sessions").run();
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
listAuthSessions(): AuthSessionRecord[] {
|
|
2363
|
+
const rows = this.db
|
|
2364
|
+
.prepare("SELECT * FROM auth_sessions ORDER BY last_used_at DESC")
|
|
2365
|
+
.all() as any[];
|
|
2366
|
+
return rows.map((row) => ({
|
|
2367
|
+
tokenHash: row.token_hash,
|
|
2368
|
+
createdAt: row.created_at,
|
|
2369
|
+
expiresAt: row.expires_at,
|
|
2370
|
+
lastUsedAt: row.last_used_at,
|
|
2371
|
+
ipAddress: row.ip_address ?? null,
|
|
2372
|
+
userAgent: row.user_agent ?? null,
|
|
2373
|
+
}));
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
cleanupExpiredAuthSessions(): number {
|
|
2377
|
+
const result = this.db
|
|
2378
|
+
.prepare("DELETE FROM auth_sessions WHERE expires_at < ?")
|
|
2379
|
+
.run(Date.now());
|
|
2380
|
+
return result.changes;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// ─── Daemon Settings Operations ──────────────────────────────────
|
|
2384
|
+
|
|
2385
|
+
getDaemonSettings(): DaemonSettingsRecord {
|
|
2386
|
+
const row = this.db.prepare("SELECT * FROM daemon_settings WHERE id = 1").get() as any;
|
|
2387
|
+
if (!row) {
|
|
2388
|
+
return {
|
|
2389
|
+
preserveSessions: false,
|
|
2390
|
+
defaultPolicyAction: "ask",
|
|
2391
|
+
forwardSshAuthSock: true,
|
|
2392
|
+
codexHostAccessProfileEnabled: false,
|
|
2393
|
+
terminalFeatures: {
|
|
2394
|
+
wsPtyPasteEnabled: true,
|
|
2395
|
+
latencyProbesEnabled: true,
|
|
2396
|
+
diagnosticsPanelEnabled: false,
|
|
2397
|
+
codexAppServerSpikeEnabled: false,
|
|
2398
|
+
wsPtyPasteCanaryPercent: 100,
|
|
2399
|
+
latencyProbesCanaryPercent: 100,
|
|
2400
|
+
diagnosticsPanelCanaryPercent: 0,
|
|
2401
|
+
},
|
|
2402
|
+
notificationsEnabled: false,
|
|
2403
|
+
systemNotificationsEnabled: false,
|
|
2404
|
+
notificationSoundsEnabled: true,
|
|
2405
|
+
notificationEventDefaults: {},
|
|
2406
|
+
notificationSoundMap: {},
|
|
2407
|
+
updatedAt: new Date(),
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
preserveSessions: row.preserve_sessions === 1,
|
|
2412
|
+
defaultPolicyAction: row.default_policy_action === "deny" ? "deny" : "ask",
|
|
2413
|
+
forwardSshAuthSock: row.forward_ssh_auth_sock !== 0,
|
|
2414
|
+
codexHostAccessProfileEnabled: row.codex_host_access_profile_enabled !== 0,
|
|
2415
|
+
terminalFeatures: {
|
|
2416
|
+
wsPtyPasteEnabled: row.terminal_ws_pty_paste_enabled !== 0,
|
|
2417
|
+
latencyProbesEnabled: row.terminal_latency_probes_enabled !== 0,
|
|
2418
|
+
diagnosticsPanelEnabled: row.terminal_diagnostics_panel_enabled !== 0,
|
|
2419
|
+
codexAppServerSpikeEnabled: row.terminal_codex_app_server_spike_enabled !== 0,
|
|
2420
|
+
wsPtyPasteCanaryPercent: this.normalizeCanaryPercent(
|
|
2421
|
+
row.terminal_ws_pty_paste_canary_percent,
|
|
2422
|
+
100
|
|
2423
|
+
),
|
|
2424
|
+
latencyProbesCanaryPercent: this.normalizeCanaryPercent(
|
|
2425
|
+
row.terminal_latency_probes_canary_percent,
|
|
2426
|
+
100
|
|
2427
|
+
),
|
|
2428
|
+
diagnosticsPanelCanaryPercent: this.normalizeCanaryPercent(
|
|
2429
|
+
row.terminal_diagnostics_panel_canary_percent,
|
|
2430
|
+
0
|
|
2431
|
+
),
|
|
2432
|
+
},
|
|
2433
|
+
notificationsEnabled: row.notifications_enabled !== 0,
|
|
2434
|
+
systemNotificationsEnabled: row.system_notifications_enabled !== 0,
|
|
2435
|
+
notificationSoundsEnabled: row.notification_sounds_enabled !== 0,
|
|
2436
|
+
notificationEventDefaults: this.parseBooleanRecord(row.notification_event_defaults_json),
|
|
2437
|
+
notificationSoundMap: this.parseStringRecord(row.notification_sound_map_json),
|
|
2438
|
+
updatedAt: new Date(row.updated_at),
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
updateDaemonSettings(params: {
|
|
2443
|
+
preserveSessions?: boolean;
|
|
2444
|
+
defaultPolicyAction?: "ask" | "deny";
|
|
2445
|
+
forwardSshAuthSock?: boolean;
|
|
2446
|
+
codexHostAccessProfileEnabled?: boolean;
|
|
2447
|
+
terminalFeatures?: Partial<DaemonTerminalFeatureSettings>;
|
|
2448
|
+
notificationsEnabled?: boolean;
|
|
2449
|
+
systemNotificationsEnabled?: boolean;
|
|
2450
|
+
notificationSoundsEnabled?: boolean;
|
|
2451
|
+
notificationEventDefaults?: Record<string, boolean>;
|
|
2452
|
+
notificationSoundMap?: Record<string, string>;
|
|
2453
|
+
}): void {
|
|
2454
|
+
const now = Date.now();
|
|
2455
|
+
const existing = this.db.prepare("SELECT id FROM daemon_settings WHERE id = 1").get();
|
|
2456
|
+
const normalizedTerminalFeatures = params.terminalFeatures
|
|
2457
|
+
? {
|
|
2458
|
+
wsPtyPasteEnabled:
|
|
2459
|
+
params.terminalFeatures.wsPtyPasteEnabled !== undefined
|
|
2460
|
+
? params.terminalFeatures.wsPtyPasteEnabled
|
|
2461
|
+
: undefined,
|
|
2462
|
+
latencyProbesEnabled:
|
|
2463
|
+
params.terminalFeatures.latencyProbesEnabled !== undefined
|
|
2464
|
+
? params.terminalFeatures.latencyProbesEnabled
|
|
2465
|
+
: undefined,
|
|
2466
|
+
diagnosticsPanelEnabled:
|
|
2467
|
+
params.terminalFeatures.diagnosticsPanelEnabled !== undefined
|
|
2468
|
+
? params.terminalFeatures.diagnosticsPanelEnabled
|
|
2469
|
+
: undefined,
|
|
2470
|
+
codexAppServerSpikeEnabled:
|
|
2471
|
+
params.terminalFeatures.codexAppServerSpikeEnabled !== undefined
|
|
2472
|
+
? params.terminalFeatures.codexAppServerSpikeEnabled
|
|
2473
|
+
: undefined,
|
|
2474
|
+
wsPtyPasteCanaryPercent:
|
|
2475
|
+
params.terminalFeatures.wsPtyPasteCanaryPercent !== undefined
|
|
2476
|
+
? this.normalizeCanaryPercent(params.terminalFeatures.wsPtyPasteCanaryPercent, 100)
|
|
2477
|
+
: undefined,
|
|
2478
|
+
latencyProbesCanaryPercent:
|
|
2479
|
+
params.terminalFeatures.latencyProbesCanaryPercent !== undefined
|
|
2480
|
+
? this.normalizeCanaryPercent(params.terminalFeatures.latencyProbesCanaryPercent, 100)
|
|
2481
|
+
: undefined,
|
|
2482
|
+
diagnosticsPanelCanaryPercent:
|
|
2483
|
+
params.terminalFeatures.diagnosticsPanelCanaryPercent !== undefined
|
|
2484
|
+
? this.normalizeCanaryPercent(
|
|
2485
|
+
params.terminalFeatures.diagnosticsPanelCanaryPercent,
|
|
2486
|
+
0
|
|
2487
|
+
)
|
|
2488
|
+
: undefined,
|
|
2489
|
+
}
|
|
2490
|
+
: undefined;
|
|
2491
|
+
|
|
2492
|
+
if (!existing) {
|
|
2493
|
+
const terminalFeatures = {
|
|
2494
|
+
wsPtyPasteEnabled: normalizedTerminalFeatures?.wsPtyPasteEnabled ?? true,
|
|
2495
|
+
latencyProbesEnabled: normalizedTerminalFeatures?.latencyProbesEnabled ?? true,
|
|
2496
|
+
diagnosticsPanelEnabled: normalizedTerminalFeatures?.diagnosticsPanelEnabled ?? false,
|
|
2497
|
+
codexAppServerSpikeEnabled: normalizedTerminalFeatures?.codexAppServerSpikeEnabled ?? false,
|
|
2498
|
+
wsPtyPasteCanaryPercent: normalizedTerminalFeatures?.wsPtyPasteCanaryPercent ?? 100,
|
|
2499
|
+
latencyProbesCanaryPercent: normalizedTerminalFeatures?.latencyProbesCanaryPercent ?? 100,
|
|
2500
|
+
diagnosticsPanelCanaryPercent:
|
|
2501
|
+
normalizedTerminalFeatures?.diagnosticsPanelCanaryPercent ?? 0,
|
|
2502
|
+
};
|
|
2503
|
+
this.db
|
|
2504
|
+
.prepare(
|
|
2505
|
+
`INSERT INTO daemon_settings (
|
|
2506
|
+
id,
|
|
2507
|
+
preserve_sessions,
|
|
2508
|
+
default_policy_action,
|
|
2509
|
+
forward_ssh_auth_sock,
|
|
2510
|
+
codex_host_access_profile_enabled,
|
|
2511
|
+
terminal_ws_pty_paste_enabled,
|
|
2512
|
+
terminal_latency_probes_enabled,
|
|
2513
|
+
terminal_diagnostics_panel_enabled,
|
|
2514
|
+
terminal_codex_app_server_spike_enabled,
|
|
2515
|
+
terminal_ws_pty_paste_canary_percent,
|
|
2516
|
+
terminal_latency_probes_canary_percent,
|
|
2517
|
+
terminal_diagnostics_panel_canary_percent,
|
|
2518
|
+
notifications_enabled,
|
|
2519
|
+
system_notifications_enabled,
|
|
2520
|
+
notification_sounds_enabled,
|
|
2521
|
+
notification_event_defaults_json,
|
|
2522
|
+
notification_sound_map_json,
|
|
2523
|
+
updated_at
|
|
2524
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2525
|
+
)
|
|
2526
|
+
.run(
|
|
2527
|
+
params.preserveSessions ? 1 : 0,
|
|
2528
|
+
params.defaultPolicyAction ?? "ask",
|
|
2529
|
+
params.forwardSshAuthSock === undefined ? 1 : params.forwardSshAuthSock ? 1 : 0,
|
|
2530
|
+
params.codexHostAccessProfileEnabled ? 1 : 0,
|
|
2531
|
+
terminalFeatures.wsPtyPasteEnabled ? 1 : 0,
|
|
2532
|
+
terminalFeatures.latencyProbesEnabled ? 1 : 0,
|
|
2533
|
+
terminalFeatures.diagnosticsPanelEnabled ? 1 : 0,
|
|
2534
|
+
terminalFeatures.codexAppServerSpikeEnabled ? 1 : 0,
|
|
2535
|
+
terminalFeatures.wsPtyPasteCanaryPercent,
|
|
2536
|
+
terminalFeatures.latencyProbesCanaryPercent,
|
|
2537
|
+
terminalFeatures.diagnosticsPanelCanaryPercent,
|
|
2538
|
+
params.notificationsEnabled ? 1 : 0,
|
|
2539
|
+
params.systemNotificationsEnabled ? 1 : 0,
|
|
2540
|
+
params.notificationSoundsEnabled === undefined
|
|
2541
|
+
? 1
|
|
2542
|
+
: params.notificationSoundsEnabled
|
|
2543
|
+
? 1
|
|
2544
|
+
: 0,
|
|
2545
|
+
this.toJsonRecord(params.notificationEventDefaults, "boolean"),
|
|
2546
|
+
this.toJsonRecord(params.notificationSoundMap, "string"),
|
|
2547
|
+
now
|
|
2548
|
+
);
|
|
2549
|
+
} else {
|
|
2550
|
+
const updates: string[] = [];
|
|
2551
|
+
const values: SQLQueryBindings[] = [];
|
|
2552
|
+
|
|
2553
|
+
if (params.preserveSessions !== undefined) {
|
|
2554
|
+
updates.push("preserve_sessions = ?");
|
|
2555
|
+
values.push(params.preserveSessions ? 1 : 0);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
if (params.defaultPolicyAction !== undefined) {
|
|
2559
|
+
updates.push("default_policy_action = ?");
|
|
2560
|
+
values.push(params.defaultPolicyAction);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
if (params.forwardSshAuthSock !== undefined) {
|
|
2564
|
+
updates.push("forward_ssh_auth_sock = ?");
|
|
2565
|
+
values.push(params.forwardSshAuthSock ? 1 : 0);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
if (params.codexHostAccessProfileEnabled !== undefined) {
|
|
2569
|
+
updates.push("codex_host_access_profile_enabled = ?");
|
|
2570
|
+
values.push(params.codexHostAccessProfileEnabled ? 1 : 0);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (normalizedTerminalFeatures?.wsPtyPasteEnabled !== undefined) {
|
|
2574
|
+
updates.push("terminal_ws_pty_paste_enabled = ?");
|
|
2575
|
+
values.push(normalizedTerminalFeatures.wsPtyPasteEnabled ? 1 : 0);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
if (normalizedTerminalFeatures?.latencyProbesEnabled !== undefined) {
|
|
2579
|
+
updates.push("terminal_latency_probes_enabled = ?");
|
|
2580
|
+
values.push(normalizedTerminalFeatures.latencyProbesEnabled ? 1 : 0);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
if (normalizedTerminalFeatures?.diagnosticsPanelEnabled !== undefined) {
|
|
2584
|
+
updates.push("terminal_diagnostics_panel_enabled = ?");
|
|
2585
|
+
values.push(normalizedTerminalFeatures.diagnosticsPanelEnabled ? 1 : 0);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
if (normalizedTerminalFeatures?.codexAppServerSpikeEnabled !== undefined) {
|
|
2589
|
+
updates.push("terminal_codex_app_server_spike_enabled = ?");
|
|
2590
|
+
values.push(normalizedTerminalFeatures.codexAppServerSpikeEnabled ? 1 : 0);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
if (normalizedTerminalFeatures?.wsPtyPasteCanaryPercent !== undefined) {
|
|
2594
|
+
updates.push("terminal_ws_pty_paste_canary_percent = ?");
|
|
2595
|
+
values.push(normalizedTerminalFeatures.wsPtyPasteCanaryPercent);
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
if (normalizedTerminalFeatures?.latencyProbesCanaryPercent !== undefined) {
|
|
2599
|
+
updates.push("terminal_latency_probes_canary_percent = ?");
|
|
2600
|
+
values.push(normalizedTerminalFeatures.latencyProbesCanaryPercent);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
if (normalizedTerminalFeatures?.diagnosticsPanelCanaryPercent !== undefined) {
|
|
2604
|
+
updates.push("terminal_diagnostics_panel_canary_percent = ?");
|
|
2605
|
+
values.push(normalizedTerminalFeatures.diagnosticsPanelCanaryPercent);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
if (params.notificationsEnabled !== undefined) {
|
|
2609
|
+
updates.push("notifications_enabled = ?");
|
|
2610
|
+
values.push(params.notificationsEnabled ? 1 : 0);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
if (params.systemNotificationsEnabled !== undefined) {
|
|
2614
|
+
updates.push("system_notifications_enabled = ?");
|
|
2615
|
+
values.push(params.systemNotificationsEnabled ? 1 : 0);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
if (params.notificationSoundsEnabled !== undefined) {
|
|
2619
|
+
updates.push("notification_sounds_enabled = ?");
|
|
2620
|
+
values.push(params.notificationSoundsEnabled ? 1 : 0);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
if (params.notificationEventDefaults !== undefined) {
|
|
2624
|
+
updates.push("notification_event_defaults_json = ?");
|
|
2625
|
+
values.push(this.toJsonRecord(params.notificationEventDefaults, "boolean"));
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
if (params.notificationSoundMap !== undefined) {
|
|
2629
|
+
updates.push("notification_sound_map_json = ?");
|
|
2630
|
+
values.push(this.toJsonRecord(params.notificationSoundMap, "string"));
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
updates.push("updated_at = ?");
|
|
2634
|
+
values.push(now);
|
|
2635
|
+
|
|
2636
|
+
this.db
|
|
2637
|
+
.prepare(`UPDATE daemon_settings SET ${updates.join(", ")} WHERE id = 1`)
|
|
2638
|
+
.run(...values);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
private normalizeCanaryPercent(value: unknown, fallback: number): number {
|
|
2643
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2644
|
+
return fallback;
|
|
2645
|
+
}
|
|
2646
|
+
const rounded = Math.round(value);
|
|
2647
|
+
if (rounded < 0) {
|
|
2648
|
+
return 0;
|
|
2649
|
+
}
|
|
2650
|
+
if (rounded > 100) {
|
|
2651
|
+
return 100;
|
|
2652
|
+
}
|
|
2653
|
+
return rounded;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
private parseBooleanRecord(value: unknown): Record<string, boolean> {
|
|
2657
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2658
|
+
return {};
|
|
2659
|
+
}
|
|
2660
|
+
try {
|
|
2661
|
+
const parsed = JSON.parse(value) as unknown;
|
|
2662
|
+
if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) {
|
|
2663
|
+
return {};
|
|
2664
|
+
}
|
|
2665
|
+
const result: Record<string, boolean> = {};
|
|
2666
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
2667
|
+
if (typeof entry === "boolean") {
|
|
2668
|
+
result[key] = entry;
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
return result;
|
|
2672
|
+
} catch {
|
|
2673
|
+
return {};
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
private parseStringRecord(value: unknown): Record<string, string> {
|
|
2678
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2679
|
+
return {};
|
|
2680
|
+
}
|
|
2681
|
+
try {
|
|
2682
|
+
const parsed = JSON.parse(value) as unknown;
|
|
2683
|
+
if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) {
|
|
2684
|
+
return {};
|
|
2685
|
+
}
|
|
2686
|
+
const result: Record<string, string> = {};
|
|
2687
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
2688
|
+
if (typeof entry === "string") {
|
|
2689
|
+
result[key] = entry;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
return result;
|
|
2693
|
+
} catch {
|
|
2694
|
+
return {};
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
private toJsonRecord(
|
|
2699
|
+
value: Record<string, unknown> | undefined,
|
|
2700
|
+
itemType: "boolean" | "string"
|
|
2701
|
+
): string {
|
|
2702
|
+
if (!(value && typeof value === "object" && !Array.isArray(value))) {
|
|
2703
|
+
return "{}";
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const normalized: Record<string, unknown> = {};
|
|
2707
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2708
|
+
if (itemType === "boolean" && typeof entry === "boolean") {
|
|
2709
|
+
normalized[key] = entry;
|
|
2710
|
+
} else if (itemType === "string" && typeof entry === "string") {
|
|
2711
|
+
normalized[key] = entry;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
return JSON.stringify(normalized);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
private mapRowToEnvSetRecord(row: any): EnvSetRecord {
|
|
2719
|
+
const { decryptVars, maskValue } = require("../crypto/encryption");
|
|
2720
|
+
const key = this.getEncryptionKey();
|
|
2721
|
+
|
|
2722
|
+
let maskedVars: Record<string, string> = {};
|
|
2723
|
+
let varCount = 0;
|
|
2724
|
+
try {
|
|
2725
|
+
const plainVars = decryptVars(row.encrypted_vars_json, key);
|
|
2726
|
+
varCount = Object.keys(plainVars).length;
|
|
2727
|
+
for (const [k, v] of Object.entries(plainVars)) {
|
|
2728
|
+
maskedVars[k] = maskValue(v);
|
|
2729
|
+
}
|
|
2730
|
+
} catch {
|
|
2731
|
+
// If decryption fails, return empty masked vars
|
|
2732
|
+
maskedVars = {};
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
return {
|
|
2736
|
+
id: row.id,
|
|
2737
|
+
name: row.name,
|
|
2738
|
+
description: row.description ?? undefined,
|
|
2739
|
+
maskedVars,
|
|
2740
|
+
varCount,
|
|
2741
|
+
createdAt: new Date(row.created_at),
|
|
2742
|
+
updatedAt: new Date(row.updated_at),
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
}
|