@ottocode/server 0.1.264 → 0.1.266

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +3 -3
  2. package/src/routes/auth/copilot.ts +699 -0
  3. package/src/routes/auth/oauth.ts +578 -0
  4. package/src/routes/auth/onboarding.ts +45 -0
  5. package/src/routes/auth/providers.ts +189 -0
  6. package/src/routes/auth/service.ts +167 -0
  7. package/src/routes/auth/state.ts +23 -0
  8. package/src/routes/auth/status.ts +203 -0
  9. package/src/routes/auth/wallet.ts +229 -0
  10. package/src/routes/auth.ts +12 -2080
  11. package/src/routes/config/models-service.ts +411 -0
  12. package/src/routes/config/models.ts +6 -426
  13. package/src/routes/config/providers-service.ts +237 -0
  14. package/src/routes/config/providers.ts +10 -242
  15. package/src/routes/files/handlers.ts +297 -0
  16. package/src/routes/files/service.ts +313 -0
  17. package/src/routes/files.ts +12 -608
  18. package/src/routes/git/commit-service.ts +207 -0
  19. package/src/routes/git/commit.ts +6 -220
  20. package/src/routes/git/remote-service.ts +116 -0
  21. package/src/routes/git/remote.ts +8 -115
  22. package/src/routes/git/staging-service.ts +111 -0
  23. package/src/routes/git/staging.ts +10 -205
  24. package/src/routes/mcp/auth.ts +338 -0
  25. package/src/routes/mcp/lifecycle.ts +263 -0
  26. package/src/routes/mcp/servers.ts +212 -0
  27. package/src/routes/mcp/service.ts +664 -0
  28. package/src/routes/mcp/state.ts +13 -0
  29. package/src/routes/mcp.ts +6 -1233
  30. package/src/routes/ottorouter/billing.ts +593 -0
  31. package/src/routes/ottorouter/service.ts +92 -0
  32. package/src/routes/ottorouter/topup.ts +301 -0
  33. package/src/routes/ottorouter/wallet.ts +370 -0
  34. package/src/routes/ottorouter.ts +6 -1319
  35. package/src/routes/research/service.ts +339 -0
  36. package/src/routes/research.ts +12 -390
  37. package/src/routes/sessions/crud.ts +563 -0
  38. package/src/routes/sessions/queue.ts +242 -0
  39. package/src/routes/sessions/retry.ts +121 -0
  40. package/src/routes/sessions/service.ts +768 -0
  41. package/src/routes/sessions/share.ts +434 -0
  42. package/src/routes/sessions.ts +8 -1977
  43. package/src/routes/skills/service.ts +221 -0
  44. package/src/routes/skills/spec.ts +309 -0
  45. package/src/routes/skills.ts +31 -909
  46. package/src/routes/terminals/service.ts +326 -0
  47. package/src/routes/terminals.ts +19 -295
  48. package/src/routes/tunnel/service.ts +217 -0
  49. package/src/routes/tunnel.ts +29 -219
  50. package/src/runtime/agent/registry-prompts.ts +147 -0
  51. package/src/runtime/agent/registry.ts +6 -124
  52. package/src/runtime/agent/runner-errors.ts +116 -0
  53. package/src/runtime/agent/runner-reminders.ts +45 -0
  54. package/src/runtime/agent/runner-setup-model.ts +75 -0
  55. package/src/runtime/agent/runner-setup-prompt.ts +185 -0
  56. package/src/runtime/agent/runner-setup-tools.ts +103 -0
  57. package/src/runtime/agent/runner-setup-utils.ts +21 -0
  58. package/src/runtime/agent/runner-setup.ts +54 -288
  59. package/src/runtime/agent/runner-telemetry.ts +112 -0
  60. package/src/runtime/agent/runner-text.ts +108 -0
  61. package/src/runtime/agent/runner-tool-observer.ts +86 -0
  62. package/src/runtime/agent/runner.ts +79 -378
  63. package/src/runtime/ask/service.ts +1 -0
  64. package/src/runtime/provider/custom.ts +73 -0
  65. package/src/runtime/provider/index.ts +6 -85
  66. package/src/runtime/provider/reasoning-builders.ts +280 -0
  67. package/src/runtime/provider/reasoning.ts +68 -264
  68. package/src/runtime/provider/xai.ts +8 -0
  69. package/src/tools/adapter/events.ts +116 -0
  70. package/src/tools/adapter/execution.ts +160 -0
  71. package/src/tools/adapter/pending.ts +37 -0
  72. package/src/tools/adapter/persistence.ts +166 -0
  73. package/src/tools/adapter/results.ts +97 -0
  74. package/src/tools/adapter.ts +124 -451
@@ -0,0 +1,768 @@
1
+ import { getDb } from '@ottocode/database';
2
+ import {
3
+ messageParts,
4
+ messages,
5
+ sessions,
6
+ shares,
7
+ } from '@ottocode/database/schema';
8
+ import {
9
+ hasConfiguredProvider,
10
+ loadConfig,
11
+ logger,
12
+ type ProviderId,
13
+ validateProviderModel,
14
+ } from '@ottocode/sdk';
15
+ import { and, desc, eq, inArray, or } from 'drizzle-orm';
16
+ import { userInfo } from 'node:os';
17
+ import { resolveAgentConfig } from '../../runtime/agent/registry.ts';
18
+ import { publish } from '../../events/bus.ts';
19
+ import { runSessionLoop } from '../../runtime/agent/runner.ts';
20
+ import {
21
+ abortMessage,
22
+ enqueueAssistantRun,
23
+ getQueueState,
24
+ getRunnerState,
25
+ removeFromQueue,
26
+ } from '../../runtime/session/queue.ts';
27
+
28
+ export const SHARE_API_URL =
29
+ process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
30
+
31
+ export type SessionRow = typeof sessions.$inferSelect;
32
+ export type MessageRow = typeof messages.$inferSelect;
33
+ export type MessagePartRow = typeof messageParts.$inferSelect;
34
+
35
+ export type ProjectDbContext = {
36
+ cfg: Awaited<ReturnType<typeof loadConfig>>;
37
+ db: Awaited<ReturnType<typeof getDb>>;
38
+ };
39
+
40
+ export function parseToolCounts(
41
+ toolCountsJson: string | null,
42
+ ): Record<string, unknown> | undefined {
43
+ if (!toolCountsJson) return undefined;
44
+ try {
45
+ const parsed = JSON.parse(toolCountsJson);
46
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
47
+ return parsed as Record<string, unknown>;
48
+ }
49
+ } catch {}
50
+ return undefined;
51
+ }
52
+
53
+ export function normalizeSessionRow(
54
+ row: SessionRow,
55
+ options: { includeRunning?: boolean } = {},
56
+ ) {
57
+ const { toolCountsJson: _toolCountsJson, ...rest } = row;
58
+ const counts = parseToolCounts(row.toolCountsJson);
59
+ const base = counts ? { ...rest, toolCounts: counts } : rest;
60
+ if (!options.includeRunning) return base;
61
+ const isRunning = getRunnerState(row.id)?.running ?? false;
62
+ return { ...base, isRunning };
63
+ }
64
+
65
+ export async function loadProjectDb(
66
+ projectRoot = process.cwd(),
67
+ ): Promise<ProjectDbContext> {
68
+ const cfg = await loadConfig(projectRoot);
69
+ const db = await getDb(cfg.projectRoot);
70
+ return { cfg, db };
71
+ }
72
+
73
+ export async function findSessionById(
74
+ db: ProjectDbContext['db'],
75
+ sessionId: string,
76
+ ) {
77
+ const rows = await db
78
+ .select()
79
+ .from(sessions)
80
+ .where(eq(sessions.id, sessionId))
81
+ .limit(1);
82
+ return rows[0] ?? null;
83
+ }
84
+
85
+ export async function deleteSessionMessagesAndParts(
86
+ db: ProjectDbContext['db'],
87
+ sessionId: string,
88
+ ) {
89
+ await db
90
+ .delete(messageParts)
91
+ .where(
92
+ inArray(
93
+ messageParts.messageId,
94
+ db
95
+ .select({ id: messages.id })
96
+ .from(messages)
97
+ .where(eq(messages.sessionId, sessionId)),
98
+ ),
99
+ );
100
+ await db.delete(messages).where(eq(messages.sessionId, sessionId));
101
+ }
102
+
103
+ export function getSessionQueueState(sessionId: string) {
104
+ return (
105
+ getQueueState(sessionId) ?? {
106
+ currentMessageId: null,
107
+ queuedMessages: [],
108
+ isRunning: false,
109
+ }
110
+ );
111
+ }
112
+
113
+ async function deleteQueuedAssistantPair(
114
+ db: ProjectDbContext['db'],
115
+ sessionId: string,
116
+ messageId: string,
117
+ ) {
118
+ const assistantMsg = await db
119
+ .select()
120
+ .from(messages)
121
+ .where(eq(messages.id, messageId))
122
+ .limit(1);
123
+
124
+ if (assistantMsg.length === 0) return;
125
+
126
+ const userMsg = await db
127
+ .select()
128
+ .from(messages)
129
+ .where(and(eq(messages.sessionId, sessionId), eq(messages.role, 'user')))
130
+ .orderBy(desc(messages.createdAt))
131
+ .limit(1);
132
+
133
+ const messageIdsToDelete = [messageId];
134
+ if (userMsg.length > 0) {
135
+ messageIdsToDelete.push(userMsg[0].id);
136
+ }
137
+
138
+ await db
139
+ .delete(messageParts)
140
+ .where(inArray(messageParts.messageId, messageIdsToDelete));
141
+ await db.delete(messages).where(inArray(messages.id, messageIdsToDelete));
142
+ }
143
+
144
+ export async function removeSessionQueueMessage(
145
+ db: ProjectDbContext['db'],
146
+ sessionId: string,
147
+ messageId: string,
148
+ ): Promise<
149
+ | { body: { success: true; removed: true; wasQueued: true }; status?: never }
150
+ | {
151
+ body: {
152
+ success: true;
153
+ removed: true;
154
+ wasQueued: false;
155
+ wasRunning: boolean;
156
+ };
157
+ status?: never;
158
+ }
159
+ | { body: { success: true; removed: true; wasStored: true }; status?: never }
160
+ | { body: { success: false; error: string }; status: 500 }
161
+ | { body: { success: false; removed: false }; status: 404 }
162
+ > {
163
+ const removed = removeFromQueue(sessionId, messageId);
164
+ if (removed) {
165
+ try {
166
+ await deleteQueuedAssistantPair(db, sessionId, messageId);
167
+ } catch (err) {
168
+ logger.error('Failed to delete queued messages from DB', err);
169
+ }
170
+ return { body: { success: true, removed: true, wasQueued: true } };
171
+ }
172
+
173
+ const result = abortMessage(sessionId, messageId);
174
+ if (result.removed) {
175
+ return {
176
+ body: {
177
+ success: true,
178
+ removed: true,
179
+ wasQueued: false,
180
+ wasRunning: result.wasRunning,
181
+ },
182
+ };
183
+ }
184
+
185
+ try {
186
+ const existingMsg = await db
187
+ .select()
188
+ .from(messages)
189
+ .where(and(eq(messages.id, messageId), eq(messages.sessionId, sessionId)))
190
+ .limit(1);
191
+
192
+ if (existingMsg.length > 0) {
193
+ await db
194
+ .delete(messageParts)
195
+ .where(
196
+ and(
197
+ eq(messageParts.messageId, messageId),
198
+ or(
199
+ eq(messageParts.type, 'error'),
200
+ and(
201
+ eq(messageParts.type, 'tool_call'),
202
+ eq(messageParts.toolName, 'finish'),
203
+ ),
204
+ ),
205
+ ),
206
+ );
207
+ await db.delete(messages).where(eq(messages.id, messageId));
208
+
209
+ return { body: { success: true, removed: true, wasStored: true } };
210
+ }
211
+ } catch (err) {
212
+ logger.error('Failed to delete message from DB', err);
213
+ return {
214
+ body: { success: false, error: 'Failed to delete message' },
215
+ status: 500,
216
+ };
217
+ }
218
+
219
+ return { body: { success: false, removed: false }, status: 404 };
220
+ }
221
+
222
+ export async function retryAssistantMessage(
223
+ cfg: ProjectDbContext['cfg'],
224
+ db: ProjectDbContext['db'],
225
+ sessionId: string,
226
+ messageId: string,
227
+ ): Promise<
228
+ | { ok: true; body: { success: true; messageId: string } }
229
+ | { ok: false; body: { error: string }; status: 400 | 404 }
230
+ > {
231
+ const [assistantMsg] = await db
232
+ .select()
233
+ .from(messages)
234
+ .where(
235
+ and(
236
+ eq(messages.id, messageId),
237
+ eq(messages.sessionId, sessionId),
238
+ eq(messages.role, 'assistant'),
239
+ ),
240
+ )
241
+ .limit(1);
242
+
243
+ if (!assistantMsg) {
244
+ return { ok: false, body: { error: 'Message not found' }, status: 404 };
245
+ }
246
+
247
+ if (assistantMsg.status !== 'error' && assistantMsg.status !== 'complete') {
248
+ return {
249
+ ok: false,
250
+ body: { error: 'Can only retry error or complete messages' },
251
+ status: 400,
252
+ };
253
+ }
254
+
255
+ const session = await findSessionById(db, sessionId);
256
+ if (!session) {
257
+ return { ok: false, body: { error: 'Session not found' }, status: 404 };
258
+ }
259
+
260
+ await db
261
+ .delete(messageParts)
262
+ .where(
263
+ and(
264
+ eq(messageParts.messageId, messageId),
265
+ or(
266
+ eq(messageParts.type, 'error'),
267
+ and(
268
+ eq(messageParts.type, 'tool_call'),
269
+ eq(messageParts.toolName, 'finish'),
270
+ ),
271
+ ),
272
+ ),
273
+ );
274
+
275
+ await db
276
+ .update(messages)
277
+ .set({
278
+ status: 'pending',
279
+ error: null,
280
+ errorType: null,
281
+ errorDetails: null,
282
+ completedAt: null,
283
+ })
284
+ .where(eq(messages.id, messageId));
285
+
286
+ publish({
287
+ type: 'message.updated',
288
+ sessionId,
289
+ payload: { id: messageId, status: 'pending' },
290
+ });
291
+
292
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'dangerous';
293
+
294
+ enqueueAssistantRun(
295
+ {
296
+ sessionId,
297
+ assistantMessageId: messageId,
298
+ agent: assistantMsg.agent ?? 'build',
299
+ provider: (assistantMsg.provider ?? cfg.defaults.provider) as ProviderId,
300
+ model: assistantMsg.model ?? cfg.defaults.model,
301
+ projectRoot: cfg.projectRoot,
302
+ oneShot: false,
303
+ toolApprovalMode,
304
+ },
305
+ runSessionLoop,
306
+ );
307
+
308
+ return { ok: true, body: { success: true, messageId } };
309
+ }
310
+
311
+ export type SessionPreferenceUpdates = {
312
+ agent?: string;
313
+ provider?: string;
314
+ model?: string;
315
+ title?: string | null;
316
+ lastActiveAt?: number;
317
+ };
318
+
319
+ export async function buildSessionPreferenceUpdates(
320
+ cfg: ProjectDbContext['cfg'],
321
+ existingSession: SessionRow,
322
+ body: Record<string, unknown>,
323
+ ): Promise<
324
+ | { ok: true; updates: SessionPreferenceUpdates }
325
+ | { ok: false; error: string; status: 400 }
326
+ > {
327
+ const updates: SessionPreferenceUpdates = {
328
+ lastActiveAt: Date.now(),
329
+ };
330
+
331
+ if (typeof body.title === 'string') {
332
+ updates.title = body.title.trim() || null;
333
+ }
334
+
335
+ if (typeof body.agent === 'string') {
336
+ const agentName = body.agent.trim();
337
+ if (agentName) {
338
+ try {
339
+ await resolveAgentConfig(cfg.projectRoot, agentName);
340
+ updates.agent = agentName;
341
+ } catch {
342
+ return { ok: false, error: `Invalid agent: ${agentName}`, status: 400 };
343
+ }
344
+ }
345
+ }
346
+
347
+ if (typeof body.provider === 'string') {
348
+ const providerName = body.provider.trim();
349
+ if (providerName && hasConfiguredProvider(cfg, providerName)) {
350
+ updates.provider = providerName;
351
+ } else if (providerName) {
352
+ return {
353
+ ok: false,
354
+ error: `Invalid provider: ${providerName}`,
355
+ status: 400,
356
+ };
357
+ }
358
+ }
359
+
360
+ if (typeof body.model === 'string') {
361
+ const modelName = body.model.trim();
362
+ if (modelName) {
363
+ const targetProvider = (updates.provider ||
364
+ existingSession.provider) as ProviderId;
365
+ try {
366
+ validateProviderModel(targetProvider, modelName, cfg);
367
+ } catch {
368
+ return {
369
+ ok: false,
370
+ error: `Model "${modelName}" not found for provider "${targetProvider}"`,
371
+ status: 400,
372
+ };
373
+ }
374
+
375
+ updates.model = modelName;
376
+ }
377
+ }
378
+
379
+ return { ok: true, updates };
380
+ }
381
+
382
+ export function getUsername(): string {
383
+ try {
384
+ return userInfo().username;
385
+ } catch {
386
+ return 'anonymous';
387
+ }
388
+ }
389
+
390
+ export function groupPartsByMessage(parts: MessagePartRow[]) {
391
+ const partsByMessage = new Map<string, MessagePartRow[]>();
392
+ for (const part of parts) {
393
+ const list = partsByMessage.get(part.messageId) || [];
394
+ list.push(part);
395
+ partsByMessage.set(part.messageId, list);
396
+ }
397
+ return partsByMessage;
398
+ }
399
+
400
+ export async function loadSessionMessagesWithParts(
401
+ db: ProjectDbContext['db'],
402
+ sessionId: string,
403
+ ) {
404
+ const allMessages = await db
405
+ .select()
406
+ .from(messages)
407
+ .where(eq(messages.sessionId, sessionId))
408
+ .orderBy(messages.createdAt);
409
+
410
+ const msgParts = allMessages.length
411
+ ? await db
412
+ .select()
413
+ .from(messageParts)
414
+ .where(
415
+ inArray(
416
+ messageParts.messageId,
417
+ allMessages.map((m) => m.id),
418
+ ),
419
+ )
420
+ .orderBy(messageParts.index)
421
+ : [];
422
+
423
+ return {
424
+ allMessages,
425
+ partsByMessage: groupPartsByMessage(msgParts),
426
+ };
427
+ }
428
+
429
+ export function buildShareSessionData(
430
+ session: SessionRow,
431
+ allMessages: MessageRow[],
432
+ partsByMessage: Map<string, MessagePartRow[]>,
433
+ ) {
434
+ return {
435
+ title: session.title,
436
+ username: getUsername(),
437
+ agent: session.agent,
438
+ provider: session.provider,
439
+ model: session.model,
440
+ createdAt: session.createdAt,
441
+ stats: {
442
+ inputTokens: session.totalInputTokens ?? 0,
443
+ outputTokens: session.totalOutputTokens ?? 0,
444
+ cachedTokens: session.totalCachedTokens ?? 0,
445
+ cacheCreationTokens: session.totalCacheCreationTokens ?? 0,
446
+ reasoningTokens: session.totalReasoningTokens ?? 0,
447
+ toolTimeMs: session.totalToolTimeMs ?? 0,
448
+ toolCounts: session.toolCountsJson
449
+ ? JSON.parse(session.toolCountsJson)
450
+ : {},
451
+ },
452
+ messages: allMessages.map((message) => ({
453
+ id: message.id,
454
+ role: message.role,
455
+ createdAt: message.createdAt,
456
+ parts: (partsByMessage.get(message.id) || []).map((part) => ({
457
+ type: part.type,
458
+ content: part.content,
459
+ toolName: part.toolName,
460
+ toolCallId: part.toolCallId,
461
+ })),
462
+ })),
463
+ };
464
+ }
465
+
466
+ export async function getShareStatus(
467
+ db: ProjectDbContext['db'],
468
+ sessionId: string,
469
+ ) {
470
+ const share = await db
471
+ .select()
472
+ .from(shares)
473
+ .where(eq(shares.sessionId, sessionId))
474
+ .limit(1);
475
+
476
+ if (!share.length) {
477
+ return { shared: false };
478
+ }
479
+
480
+ const allMessages = await db
481
+ .select({ id: messages.id })
482
+ .from(messages)
483
+ .where(eq(messages.sessionId, sessionId))
484
+ .orderBy(messages.createdAt);
485
+
486
+ const totalMessages = allMessages.length;
487
+ const syncedIdx = allMessages.findIndex(
488
+ (message) => message.id === share[0].lastSyncedMessageId,
489
+ );
490
+ const syncedMessages = syncedIdx === -1 ? 0 : syncedIdx + 1;
491
+ const pendingMessages = totalMessages - syncedMessages;
492
+
493
+ return {
494
+ shared: true,
495
+ shareId: share[0].shareId,
496
+ url: share[0].url,
497
+ title: share[0].title,
498
+ createdAt: share[0].createdAt,
499
+ lastSyncedAt: share[0].lastSyncedAt,
500
+ lastSyncedMessageId: share[0].lastSyncedMessageId,
501
+ syncedMessages,
502
+ totalMessages,
503
+ pendingMessages,
504
+ isSynced: pendingMessages === 0,
505
+ };
506
+ }
507
+
508
+ export async function createShare(
509
+ db: ProjectDbContext['db'],
510
+ sessionId: string,
511
+ ): Promise<
512
+ | { ok: true; body: { shared: true; shareId: string; url: string } }
513
+ | { ok: false; body: { error: string }; status: 400 | 404 | 500 }
514
+ | {
515
+ ok: true;
516
+ body: { shared: true; shareId: string; url: string; message: string };
517
+ }
518
+ > {
519
+ const session = await findSessionById(db, sessionId);
520
+ if (!session) {
521
+ return { ok: false, body: { error: 'Session not found' }, status: 404 };
522
+ }
523
+
524
+ const existingShare = await db
525
+ .select()
526
+ .from(shares)
527
+ .where(eq(shares.sessionId, sessionId))
528
+ .limit(1);
529
+ if (existingShare.length) {
530
+ return {
531
+ ok: true,
532
+ body: {
533
+ shared: true,
534
+ shareId: existingShare[0].shareId,
535
+ url: existingShare[0].url,
536
+ message: 'Already shared',
537
+ },
538
+ };
539
+ }
540
+
541
+ const { allMessages, partsByMessage } = await loadSessionMessagesWithParts(
542
+ db,
543
+ sessionId,
544
+ );
545
+
546
+ if (!allMessages.length) {
547
+ return {
548
+ ok: false,
549
+ body: { error: 'Session has no messages' },
550
+ status: 400,
551
+ };
552
+ }
553
+
554
+ const lastMessageId = allMessages[allMessages.length - 1].id;
555
+ const sessionData = buildShareSessionData(
556
+ session,
557
+ allMessages,
558
+ partsByMessage,
559
+ );
560
+
561
+ const res = await fetch(`${SHARE_API_URL}/share`, {
562
+ method: 'POST',
563
+ headers: { 'Content-Type': 'application/json' },
564
+ body: JSON.stringify({
565
+ sessionData,
566
+ title: session.title,
567
+ lastMessageId,
568
+ }),
569
+ });
570
+
571
+ if (!res.ok) {
572
+ const err = await res.text();
573
+ return {
574
+ ok: false,
575
+ body: { error: `Failed to create share: ${err}` },
576
+ status: 500,
577
+ };
578
+ }
579
+
580
+ const data = (await res.json()) as {
581
+ shareId: string;
582
+ secret: string;
583
+ url: string;
584
+ };
585
+
586
+ await db.insert(shares).values({
587
+ sessionId,
588
+ shareId: data.shareId,
589
+ secret: data.secret,
590
+ url: data.url,
591
+ title: session.title,
592
+ description: null,
593
+ createdAt: Date.now(),
594
+ lastSyncedAt: Date.now(),
595
+ lastSyncedMessageId: lastMessageId,
596
+ });
597
+
598
+ return {
599
+ ok: true,
600
+ body: {
601
+ shared: true,
602
+ shareId: data.shareId,
603
+ url: data.url,
604
+ },
605
+ };
606
+ }
607
+
608
+ export async function syncShare(
609
+ db: ProjectDbContext['db'],
610
+ sessionId: string,
611
+ ): Promise<
612
+ | { ok: true; body: { synced: true; url: string; newMessages: number } }
613
+ | {
614
+ ok: true;
615
+ body: { synced: true; url: string; newMessages: 0; message: string };
616
+ }
617
+ | { ok: false; body: { error: string }; status: 400 | 404 | 500 }
618
+ > {
619
+ const share = await db
620
+ .select()
621
+ .from(shares)
622
+ .where(eq(shares.sessionId, sessionId))
623
+ .limit(1);
624
+ if (!share.length) {
625
+ return {
626
+ ok: false,
627
+ body: { error: 'Session not shared. Use share first.' },
628
+ status: 400,
629
+ };
630
+ }
631
+
632
+ const session = await findSessionById(db, sessionId);
633
+ if (!session) {
634
+ return { ok: false, body: { error: 'Session not found' }, status: 404 };
635
+ }
636
+
637
+ const { allMessages, partsByMessage } = await loadSessionMessagesWithParts(
638
+ db,
639
+ sessionId,
640
+ );
641
+
642
+ const lastSyncedIdx = allMessages.findIndex(
643
+ (message) => message.id === share[0].lastSyncedMessageId,
644
+ );
645
+ const newMessages =
646
+ lastSyncedIdx === -1 ? allMessages : allMessages.slice(lastSyncedIdx + 1);
647
+ const lastMessageId =
648
+ allMessages[allMessages.length - 1]?.id ?? share[0].lastSyncedMessageId;
649
+
650
+ if (newMessages.length === 0) {
651
+ return {
652
+ ok: true,
653
+ body: {
654
+ synced: true,
655
+ url: share[0].url,
656
+ newMessages: 0,
657
+ message: 'Already synced',
658
+ },
659
+ };
660
+ }
661
+
662
+ const sessionData = buildShareSessionData(
663
+ session,
664
+ allMessages,
665
+ partsByMessage,
666
+ );
667
+
668
+ const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
669
+ method: 'PUT',
670
+ headers: {
671
+ 'Content-Type': 'application/json',
672
+ 'X-Share-Secret': share[0].secret,
673
+ },
674
+ body: JSON.stringify({
675
+ sessionData,
676
+ title: session.title,
677
+ lastMessageId,
678
+ }),
679
+ });
680
+
681
+ if (!res.ok) {
682
+ const err = await res.text();
683
+ return {
684
+ ok: false,
685
+ body: { error: `Failed to sync share: ${err}` },
686
+ status: 500,
687
+ };
688
+ }
689
+
690
+ await db
691
+ .update(shares)
692
+ .set({
693
+ title: session.title,
694
+ lastSyncedAt: Date.now(),
695
+ lastSyncedMessageId: lastMessageId,
696
+ })
697
+ .where(eq(shares.sessionId, sessionId));
698
+
699
+ return {
700
+ ok: true,
701
+ body: {
702
+ synced: true,
703
+ url: share[0].url,
704
+ newMessages: newMessages.length,
705
+ },
706
+ };
707
+ }
708
+
709
+ export async function deleteShare(
710
+ db: ProjectDbContext['db'],
711
+ sessionId: string,
712
+ ): Promise<
713
+ | { ok: true; body: { deleted: true; sessionId: string } }
714
+ | { ok: false; body: { error: string }; status: 404 | 500 }
715
+ > {
716
+ const share = await db
717
+ .select()
718
+ .from(shares)
719
+ .where(eq(shares.sessionId, sessionId))
720
+ .limit(1);
721
+
722
+ if (!share.length) {
723
+ return {
724
+ ok: false,
725
+ body: { error: 'Session is not shared' },
726
+ status: 404,
727
+ };
728
+ }
729
+
730
+ try {
731
+ const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
732
+ method: 'DELETE',
733
+ headers: { 'X-Share-Secret': share[0].secret },
734
+ });
735
+
736
+ if (!res.ok && res.status !== 404) {
737
+ const err = await res.text();
738
+ return {
739
+ ok: false,
740
+ body: { error: `Failed to delete share: ${err}` },
741
+ status: 500,
742
+ };
743
+ }
744
+ } catch {}
745
+
746
+ await db.delete(shares).where(eq(shares.sessionId, sessionId));
747
+
748
+ return { ok: true, body: { deleted: true, sessionId } };
749
+ }
750
+
751
+ export async function listShares(
752
+ cfg: ProjectDbContext['cfg'],
753
+ db: ProjectDbContext['db'],
754
+ ) {
755
+ return db
756
+ .select({
757
+ sessionId: shares.sessionId,
758
+ shareId: shares.shareId,
759
+ url: shares.url,
760
+ title: shares.title,
761
+ createdAt: shares.createdAt,
762
+ lastSyncedAt: shares.lastSyncedAt,
763
+ })
764
+ .from(shares)
765
+ .innerJoin(sessions, eq(shares.sessionId, sessions.id))
766
+ .where(eq(sessions.projectPath, cfg.projectRoot))
767
+ .orderBy(desc(shares.lastSyncedAt));
768
+ }