@myrialabs/clopen 0.1.8 → 0.1.10
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/backend/index.ts +5 -1
- package/backend/lib/chat/stream-manager.ts +4 -1
- package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
- package/backend/lib/database/migrations/index.ts +7 -0
- package/backend/lib/database/queries/session-queries.ts +50 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +8 -0
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
- package/backend/lib/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- package/backend/ws/chat/stream.ts +13 -0
- package/backend/ws/sessions/crud.ts +34 -2
- package/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/backend/ws/user/crud.ts +8 -4
- package/bin/clopen.ts +17 -1
- package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/git/GitLog.svelte +26 -12
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
- package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
- package/frontend/lib/components/terminal/Terminal.svelte +1 -1
- package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
- package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/stores/core/app.svelte.ts +46 -0
- package/frontend/lib/stores/core/sessions.svelte.ts +39 -4
- package/frontend/lib/stores/ui/update.svelte.ts +0 -12
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- package/package.json +1 -1
|
@@ -285,47 +285,18 @@ export class SnapshotService {
|
|
|
285
285
|
*/
|
|
286
286
|
async checkRestoreConflicts(
|
|
287
287
|
sessionId: string,
|
|
288
|
-
targetCheckpointMessageId: string,
|
|
289
|
-
projectPath?: string
|
|
288
|
+
targetCheckpointMessageId: string | null,
|
|
289
|
+
projectPath?: string,
|
|
290
|
+
targetPath?: string[]
|
|
290
291
|
): Promise<RestoreConflictCheck> {
|
|
291
292
|
const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
|
|
293
|
+
const isInitialRestore = targetCheckpointMessageId === null;
|
|
292
294
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
+
// Build expected state at target (branch-aware when targetPath is provided)
|
|
296
|
+
const expectedState = this.buildExpectedState(
|
|
297
|
+
sessionSnapshots, targetCheckpointMessageId, targetPath
|
|
295
298
|
);
|
|
296
299
|
|
|
297
|
-
if (targetIndex === -1) {
|
|
298
|
-
return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Build expected state at target (same bidirectional algorithm as restoreSessionScoped)
|
|
302
|
-
// This determines ALL files that would be affected by the restore
|
|
303
|
-
const expectedState = new Map<string, string>(); // filepath → expectedHash
|
|
304
|
-
|
|
305
|
-
for (let i = 0; i <= targetIndex; i++) {
|
|
306
|
-
const snap = sessionSnapshots[i];
|
|
307
|
-
if (!snap.session_changes) continue;
|
|
308
|
-
try {
|
|
309
|
-
const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
|
|
310
|
-
for (const [filepath, change] of Object.entries(changes)) {
|
|
311
|
-
expectedState.set(filepath, change.newHash);
|
|
312
|
-
}
|
|
313
|
-
} catch { /* skip malformed */ }
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
|
|
317
|
-
const snap = sessionSnapshots[i];
|
|
318
|
-
if (!snap.session_changes) continue;
|
|
319
|
-
try {
|
|
320
|
-
const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
|
|
321
|
-
for (const [filepath, change] of Object.entries(changes)) {
|
|
322
|
-
if (!expectedState.has(filepath)) {
|
|
323
|
-
expectedState.set(filepath, change.oldHash);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
} catch { /* skip malformed */ }
|
|
327
|
-
}
|
|
328
|
-
|
|
329
300
|
if (expectedState.size === 0) {
|
|
330
301
|
return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
|
|
331
302
|
}
|
|
@@ -354,8 +325,13 @@ export class SnapshotService {
|
|
|
354
325
|
|
|
355
326
|
// Determine reference time for cross-session conflict check
|
|
356
327
|
// Use min(targetTime, currentHeadTime) to cover both undo and redo
|
|
357
|
-
|
|
358
|
-
const
|
|
328
|
+
// For initial restore, use the session's created_at or the earliest snapshot time
|
|
329
|
+
const targetSnapshot = isInitialRestore
|
|
330
|
+
? null
|
|
331
|
+
: sessionSnapshots.find(s => s.message_id === targetCheckpointMessageId) || null;
|
|
332
|
+
const targetTime = targetSnapshot
|
|
333
|
+
? targetSnapshot.created_at
|
|
334
|
+
: (sessionSnapshots[0]?.created_at || new Date(0).toISOString());
|
|
359
335
|
let referenceTime = targetTime;
|
|
360
336
|
|
|
361
337
|
const currentHead = sessionQueries.getHead(sessionId);
|
|
@@ -384,7 +360,9 @@ export class SnapshotService {
|
|
|
384
360
|
|
|
385
361
|
// Check for cross-session conflicts
|
|
386
362
|
const conflicts: RestoreConflict[] = [];
|
|
387
|
-
const projectId = targetSnapshot
|
|
363
|
+
const projectId = targetSnapshot
|
|
364
|
+
? targetSnapshot.project_id
|
|
365
|
+
: (sessionSnapshots[0]?.project_id || '');
|
|
388
366
|
const allProjectSnapshots = this.getAllProjectSnapshots(projectId);
|
|
389
367
|
|
|
390
368
|
for (const otherSnap of allProjectSnapshots) {
|
|
@@ -472,60 +450,29 @@ export class SnapshotService {
|
|
|
472
450
|
* Restore to a checkpoint using session-scoped changes.
|
|
473
451
|
* Works in both directions (forward and backward).
|
|
474
452
|
*
|
|
475
|
-
*
|
|
476
|
-
* 1.
|
|
477
|
-
* 2.
|
|
478
|
-
* 3.
|
|
453
|
+
* When targetPath is provided, uses branch-aware algorithm:
|
|
454
|
+
* 1. Only apply changes from snapshots on the path (root → target)
|
|
455
|
+
* 2. Revert ALL changes from snapshots on other branches
|
|
456
|
+
* 3. Compare expected state with disk and restore if different
|
|
479
457
|
* 4. Update in-memory baseline to match restored state
|
|
458
|
+
*
|
|
459
|
+
* Falls back to linear algorithm when targetPath is not provided.
|
|
480
460
|
*/
|
|
481
461
|
async restoreSessionScoped(
|
|
482
462
|
projectPath: string,
|
|
483
463
|
sessionId: string,
|
|
484
|
-
targetCheckpointMessageId: string,
|
|
485
|
-
conflictResolutions?: ConflictResolution
|
|
464
|
+
targetCheckpointMessageId: string | null,
|
|
465
|
+
conflictResolutions?: ConflictResolution,
|
|
466
|
+
targetPath?: string[]
|
|
486
467
|
): Promise<{ restoredFiles: number; skippedFiles: number }> {
|
|
487
468
|
try {
|
|
488
469
|
const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
|
|
489
470
|
|
|
490
|
-
const targetIndex = sessionSnapshots.findIndex(
|
|
491
|
-
s => s.message_id === targetCheckpointMessageId
|
|
492
|
-
);
|
|
493
|
-
|
|
494
|
-
if (targetIndex === -1) {
|
|
495
|
-
debug.warn('snapshot', 'Target checkpoint snapshot not found');
|
|
496
|
-
return { restoredFiles: 0, skippedFiles: 0 };
|
|
497
|
-
}
|
|
498
|
-
|
|
499
471
|
// Build expected file state at the target checkpoint
|
|
500
|
-
//
|
|
501
|
-
const expectedState =
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
for (let i = 0; i <= targetIndex; i++) {
|
|
505
|
-
const snap = sessionSnapshots[i];
|
|
506
|
-
if (!snap.session_changes) continue;
|
|
507
|
-
try {
|
|
508
|
-
const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
|
|
509
|
-
for (const [filepath, change] of Object.entries(changes)) {
|
|
510
|
-
expectedState.set(filepath, change.newHash);
|
|
511
|
-
}
|
|
512
|
-
} catch { /* skip */ }
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Walk snapshots after target: files changed only after target need reverting to oldHash
|
|
516
|
-
for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
|
|
517
|
-
const snap = sessionSnapshots[i];
|
|
518
|
-
if (!snap.session_changes) continue;
|
|
519
|
-
try {
|
|
520
|
-
const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
|
|
521
|
-
for (const [filepath, change] of Object.entries(changes)) {
|
|
522
|
-
if (!expectedState.has(filepath)) {
|
|
523
|
-
// File was first changed AFTER target → revert to pre-change state
|
|
524
|
-
expectedState.set(filepath, change.oldHash);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
} catch { /* skip */ }
|
|
528
|
-
}
|
|
472
|
+
// Branch-aware when targetPath is provided
|
|
473
|
+
const expectedState = this.buildExpectedState(
|
|
474
|
+
sessionSnapshots, targetCheckpointMessageId, targetPath
|
|
475
|
+
);
|
|
529
476
|
|
|
530
477
|
debug.log('snapshot', `Restore to checkpoint: ${expectedState.size} files in expected state`);
|
|
531
478
|
|
|
@@ -600,6 +547,124 @@ export class SnapshotService {
|
|
|
600
547
|
// Helpers
|
|
601
548
|
// ========================================================================
|
|
602
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Build the expected file state map for a restore operation.
|
|
552
|
+
*
|
|
553
|
+
* Branch-aware algorithm (when targetPath is provided):
|
|
554
|
+
* 1. Separate snapshots into path (root→target) vs non-path
|
|
555
|
+
* 2. Forward walk: only apply newHash from snapshots on the path (in path order)
|
|
556
|
+
* 3. Revert walk: revert ALL non-path snapshots using oldHash (first-wins)
|
|
557
|
+
*
|
|
558
|
+
* This correctly handles multi-branch checkpoint trees by NOT including
|
|
559
|
+
* changes from other branches in the forward walk.
|
|
560
|
+
*
|
|
561
|
+
* Fallback linear algorithm (when targetPath is not provided):
|
|
562
|
+
* Walks all snapshots chronologically — only correct for single-branch paths.
|
|
563
|
+
*/
|
|
564
|
+
private buildExpectedState(
|
|
565
|
+
sessionSnapshots: MessageSnapshot[],
|
|
566
|
+
targetCheckpointMessageId: string | null,
|
|
567
|
+
targetPath?: string[]
|
|
568
|
+
): Map<string, string> {
|
|
569
|
+
const expectedState = new Map<string, string>();
|
|
570
|
+
const isInitialRestore = targetCheckpointMessageId === null;
|
|
571
|
+
|
|
572
|
+
if (isInitialRestore) {
|
|
573
|
+
// Revert ALL snapshots → everything goes back to oldHash (first-wins)
|
|
574
|
+
for (const snap of sessionSnapshots) {
|
|
575
|
+
if (!snap.session_changes) continue;
|
|
576
|
+
try {
|
|
577
|
+
const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
|
|
578
|
+
for (const [filepath, change] of Object.entries(changes)) {
|
|
579
|
+
if (!expectedState.has(filepath)) {
|
|
580
|
+
expectedState.set(filepath, change.oldHash);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} catch { /* skip malformed */ }
|
|
584
|
+
}
|
|
585
|
+
} else if (targetPath && targetPath.length > 0) {
|
|
586
|
+
// Branch-aware restore: only include snapshots on the path from root to target
|
|
587
|
+
const pathSet = new Set(targetPath);
|
|
588
|
+
|
|
589
|
+
// Separate snapshots into path vs non-path
|
|
590
|
+
const snapshotByMsgId = new Map<string, MessageSnapshot>();
|
|
591
|
+
const nonPathSnapshots: MessageSnapshot[] = [];
|
|
592
|
+
|
|
593
|
+
for (const snap of sessionSnapshots) {
|
|
594
|
+
if (pathSet.has(snap.message_id)) {
|
|
595
|
+
snapshotByMsgId.set(snap.message_id, snap);
|
|
596
|
+
} else {
|
|
597
|
+
nonPathSnapshots.push(snap);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Forward walk: apply path snapshots in path order (root → target)
|
|
602
|
+
// Later path snapshots overwrite earlier ones for the same file (correct)
|
|
603
|
+
for (const cpId of targetPath) {
|
|
604
|
+
const snap = snapshotByMsgId.get(cpId);
|
|
605
|
+
if (!snap?.session_changes) continue;
|
|
606
|
+
try {
|
|
607
|
+
const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
|
|
608
|
+
for (const [filepath, change] of Object.entries(changes)) {
|
|
609
|
+
expectedState.set(filepath, change.newHash);
|
|
610
|
+
}
|
|
611
|
+
} catch { /* skip malformed */ }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Revert all non-path snapshots (changes on other branches)
|
|
615
|
+
// Process in chronological order with first-wins semantics:
|
|
616
|
+
// if two non-path snapshots change the same file, the earliest one's
|
|
617
|
+
// oldHash is used (state before any branch diverged)
|
|
618
|
+
for (const snap of nonPathSnapshots) {
|
|
619
|
+
if (!snap.session_changes) continue;
|
|
620
|
+
try {
|
|
621
|
+
const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
|
|
622
|
+
for (const [filepath, change] of Object.entries(changes)) {
|
|
623
|
+
if (!expectedState.has(filepath)) {
|
|
624
|
+
expectedState.set(filepath, change.oldHash);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch { /* skip malformed */ }
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// Fallback: linear algorithm (no path info available)
|
|
631
|
+
const targetIndex = sessionSnapshots.findIndex(
|
|
632
|
+
s => s.message_id === targetCheckpointMessageId
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (targetIndex === -1) {
|
|
636
|
+
debug.warn('snapshot', 'Target checkpoint snapshot not found (linear fallback)');
|
|
637
|
+
return expectedState;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (let i = 0; i <= targetIndex; i++) {
|
|
641
|
+
const snap = sessionSnapshots[i];
|
|
642
|
+
if (!snap.session_changes) continue;
|
|
643
|
+
try {
|
|
644
|
+
const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
|
|
645
|
+
for (const [filepath, change] of Object.entries(changes)) {
|
|
646
|
+
expectedState.set(filepath, change.newHash);
|
|
647
|
+
}
|
|
648
|
+
} catch { /* skip malformed */ }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
|
|
652
|
+
const snap = sessionSnapshots[i];
|
|
653
|
+
if (!snap.session_changes) continue;
|
|
654
|
+
try {
|
|
655
|
+
const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
|
|
656
|
+
for (const [filepath, change] of Object.entries(changes)) {
|
|
657
|
+
if (!expectedState.has(filepath)) {
|
|
658
|
+
expectedState.set(filepath, change.oldHash);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch { /* skip malformed */ }
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return expectedState;
|
|
666
|
+
}
|
|
667
|
+
|
|
603
668
|
/**
|
|
604
669
|
* Calculate line-level change stats for changed files.
|
|
605
670
|
*/
|
|
@@ -49,6 +49,14 @@ streamManager.on('stream:lifecycle', (event: { status: string; streamId: string;
|
|
|
49
49
|
broadcastPresence().catch(() => {});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
// Notify project members when a snapshot is captured (so the timeline modal can refresh stats)
|
|
53
|
+
streamManager.on('snapshot:captured', (event: { projectId: string; chatSessionId: string }) => {
|
|
54
|
+
const { projectId, chatSessionId } = event;
|
|
55
|
+
if (!projectId) return;
|
|
56
|
+
|
|
57
|
+
ws.emit.projectMembers(projectId, 'snapshot:captured', { projectId, chatSessionId });
|
|
58
|
+
});
|
|
59
|
+
|
|
52
60
|
// In-memory store for latest chat input state per chat session (keyed by chatSessionId)
|
|
53
61
|
const chatSessionInputState = new Map<string, { text: string; senderId: string; attachments?: any[] }>();
|
|
54
62
|
|
|
@@ -789,4 +797,9 @@ export const streamHandler = createRouter()
|
|
|
789
797
|
chatSessionId: t.String(),
|
|
790
798
|
toolUseId: t.String(),
|
|
791
799
|
timestamp: t.String()
|
|
800
|
+
}))
|
|
801
|
+
|
|
802
|
+
.emit('snapshot:captured', t.Object({
|
|
803
|
+
projectId: t.String(),
|
|
804
|
+
chatSessionId: t.String()
|
|
792
805
|
}));
|
|
@@ -33,7 +33,11 @@ export const crudHandler = createRouter()
|
|
|
33
33
|
started_at: t.String(),
|
|
34
34
|
ended_at: t.Optional(t.String())
|
|
35
35
|
})),
|
|
36
|
-
currentSessionId: t.Optional(t.String())
|
|
36
|
+
currentSessionId: t.Optional(t.String()),
|
|
37
|
+
unreadSessionIds: t.Array(t.Object({
|
|
38
|
+
sessionId: t.String(),
|
|
39
|
+
projectId: t.String()
|
|
40
|
+
}))
|
|
37
41
|
})
|
|
38
42
|
}, async ({ conn }) => {
|
|
39
43
|
const projectId = ws.getProjectId(conn);
|
|
@@ -43,6 +47,10 @@ export const crudHandler = createRouter()
|
|
|
43
47
|
// Get the user's saved current session for this project
|
|
44
48
|
const currentSessionId = projectQueries.getCurrentSessionId(userId, projectId);
|
|
45
49
|
|
|
50
|
+
// Get unread sessions for this user/project
|
|
51
|
+
const unreadRows = sessionQueries.getUnreadSessions(userId, projectId);
|
|
52
|
+
debug.log('session', `[unread] sessions:list — user=${userId}, project=${projectId}, unreadCount=${unreadRows.length}`, unreadRows);
|
|
53
|
+
|
|
46
54
|
// Convert null to undefined for TypeScript optional fields
|
|
47
55
|
return {
|
|
48
56
|
sessions: sessions.map(session => ({
|
|
@@ -54,7 +62,8 @@ export const crudHandler = createRouter()
|
|
|
54
62
|
current_head_message_id: session.current_head_message_id ?? undefined,
|
|
55
63
|
ended_at: session.ended_at ?? undefined
|
|
56
64
|
})),
|
|
57
|
-
currentSessionId: currentSessionId ?? undefined
|
|
65
|
+
currentSessionId: currentSessionId ?? undefined,
|
|
66
|
+
unreadSessionIds: unreadRows.map(r => ({ sessionId: r.session_id, projectId: r.project_id }))
|
|
58
67
|
};
|
|
59
68
|
})
|
|
60
69
|
|
|
@@ -324,4 +333,27 @@ export const crudHandler = createRouter()
|
|
|
324
333
|
const userId = ws.getUserId(conn);
|
|
325
334
|
projectQueries.setCurrentSessionId(userId, projectId, data.sessionId);
|
|
326
335
|
debug.log('session', `User ${userId} set current session to ${data.sessionId} in project ${projectId}`);
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// Mark a session as read for the current user
|
|
339
|
+
.on('sessions:mark-read', {
|
|
340
|
+
data: t.Object({
|
|
341
|
+
sessionId: t.String()
|
|
342
|
+
})
|
|
343
|
+
}, async ({ data, conn }) => {
|
|
344
|
+
const userId = ws.getUserId(conn);
|
|
345
|
+
sessionQueries.markRead(userId, data.sessionId);
|
|
346
|
+
debug.log('session', `[unread] Marked session ${data.sessionId} as READ for user ${userId}`);
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// Mark a session as unread for the current user
|
|
350
|
+
.on('sessions:mark-unread', {
|
|
351
|
+
data: t.Object({
|
|
352
|
+
sessionId: t.String(),
|
|
353
|
+
projectId: t.String()
|
|
354
|
+
})
|
|
355
|
+
}, async ({ data, conn }) => {
|
|
356
|
+
const userId = ws.getUserId(conn);
|
|
357
|
+
sessionQueries.markUnread(userId, data.sessionId, data.projectId);
|
|
358
|
+
debug.log('session', `[unread] Marked session ${data.sessionId} as UNREAD for user ${userId} in project ${data.projectId}`);
|
|
327
359
|
});
|
|
@@ -14,7 +14,9 @@ import { debug } from '$shared/utils/logger';
|
|
|
14
14
|
import {
|
|
15
15
|
buildCheckpointTree,
|
|
16
16
|
getCheckpointPathToRoot,
|
|
17
|
-
|
|
17
|
+
findCheckpointForHead,
|
|
18
|
+
findSessionEnd,
|
|
19
|
+
INITIAL_NODE_ID
|
|
18
20
|
} from '../../lib/snapshot/helpers';
|
|
19
21
|
import { ws } from '$backend/lib/utils/ws';
|
|
20
22
|
|
|
@@ -53,7 +55,31 @@ export const restoreHandler = createRouter()
|
|
|
53
55
|
if (project) projectPath = project.path;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
// Build checkpoint path for branch-aware conflict detection
|
|
59
|
+
let targetPath: string[] | undefined;
|
|
60
|
+
let resolvedMessageId: string | null = messageId === INITIAL_NODE_ID ? null : messageId;
|
|
61
|
+
|
|
62
|
+
if (messageId !== INITIAL_NODE_ID) {
|
|
63
|
+
const allMessages = messageQueries.getAllBySessionId(sessionId);
|
|
64
|
+
const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
|
|
65
|
+
const checkpointIdSet = new Set(checkpoints.map(c => c.id));
|
|
66
|
+
|
|
67
|
+
const resolvedId = checkpointIdSet.has(messageId)
|
|
68
|
+
? messageId
|
|
69
|
+
: findCheckpointForHead(messageId, allMessages, checkpointIdSet);
|
|
70
|
+
|
|
71
|
+
if (resolvedId) {
|
|
72
|
+
resolvedMessageId = resolvedId;
|
|
73
|
+
targetPath = getCheckpointPathToRoot(resolvedId, parentMap);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await snapshotService.checkRestoreConflicts(
|
|
78
|
+
sessionId,
|
|
79
|
+
resolvedMessageId,
|
|
80
|
+
projectPath,
|
|
81
|
+
targetPath
|
|
82
|
+
);
|
|
57
83
|
|
|
58
84
|
debug.log('snapshot', `Conflict check: ${result.conflicts.length} conflicts, ${result.checkpointsToUndo.length} checkpoints to undo`);
|
|
59
85
|
|
|
@@ -82,12 +108,67 @@ export const restoreHandler = createRouter()
|
|
|
82
108
|
})
|
|
83
109
|
}, async ({ data }) => {
|
|
84
110
|
const { messageId, sessionId, conflictResolutions } = data;
|
|
111
|
+
const isInitialRestore = messageId === INITIAL_NODE_ID;
|
|
85
112
|
|
|
86
|
-
debug.log('snapshot',
|
|
87
|
-
debug.log('snapshot', `Target
|
|
113
|
+
debug.log('snapshot', `RESTORE - ${isInitialRestore ? 'Restoring to initial state' : 'Moving HEAD to checkpoint'}`);
|
|
114
|
+
debug.log('snapshot', `Target: ${messageId}`);
|
|
88
115
|
debug.log('snapshot', `Session: ${sessionId}`);
|
|
89
116
|
|
|
90
|
-
//
|
|
117
|
+
// Handle restore to initial state (before any messages)
|
|
118
|
+
if (isInitialRestore) {
|
|
119
|
+
// Clear HEAD (no messages active)
|
|
120
|
+
sessionQueries.clearHead(sessionId);
|
|
121
|
+
debug.log('snapshot', 'HEAD cleared (initial state)');
|
|
122
|
+
|
|
123
|
+
// Clear latest_sdk_session_id so next chat starts fresh
|
|
124
|
+
const db = (await import('../../lib/database')).getDatabase();
|
|
125
|
+
db.prepare(`UPDATE chat_sessions SET latest_sdk_session_id = NULL WHERE id = ?`).run(sessionId);
|
|
126
|
+
|
|
127
|
+
// Clear checkpoint_tree_state
|
|
128
|
+
checkpointQueries.deleteForSession(sessionId);
|
|
129
|
+
|
|
130
|
+
// Restore file system: revert ALL session changes
|
|
131
|
+
let filesRestored = 0;
|
|
132
|
+
let filesSkipped = 0;
|
|
133
|
+
|
|
134
|
+
const session = sessionQueries.getById(sessionId);
|
|
135
|
+
if (session) {
|
|
136
|
+
const project = projectQueries.getById(session.project_id);
|
|
137
|
+
if (project) {
|
|
138
|
+
const result = await snapshotService.restoreSessionScoped(
|
|
139
|
+
project.path,
|
|
140
|
+
sessionId,
|
|
141
|
+
null, // null = restore to initial (before all snapshots)
|
|
142
|
+
conflictResolutions
|
|
143
|
+
);
|
|
144
|
+
filesRestored = result.restoredFiles;
|
|
145
|
+
filesSkipped = result.skippedFiles;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Broadcast messages-changed
|
|
150
|
+
try {
|
|
151
|
+
ws.emit.chatSession(sessionId, 'chat:messages-changed', {
|
|
152
|
+
sessionId,
|
|
153
|
+
reason: 'restore',
|
|
154
|
+
timestamp: new Date().toISOString()
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
debug.error('snapshot', 'Failed to broadcast messages-changed:', err);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
restoredTo: {
|
|
162
|
+
messageId: INITIAL_NODE_ID,
|
|
163
|
+
timestamp: new Date().toISOString()
|
|
164
|
+
},
|
|
165
|
+
filesRestored,
|
|
166
|
+
filesSkipped
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Regular checkpoint restore
|
|
171
|
+
// 1. Get the target message
|
|
91
172
|
const checkpointMessage = messageQueries.getById(messageId);
|
|
92
173
|
if (!checkpointMessage) {
|
|
93
174
|
throw new Error('Checkpoint message not found');
|
|
@@ -99,9 +180,19 @@ export const restoreHandler = createRouter()
|
|
|
99
180
|
|
|
100
181
|
// 3. Get all messages and build checkpoint tree
|
|
101
182
|
const allMessages = messageQueries.getAllBySessionId(sessionId);
|
|
102
|
-
const { parentMap } = buildCheckpointTree(allMessages);
|
|
183
|
+
const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
|
|
184
|
+
|
|
185
|
+
// 3b. Resolve the correct checkpoint for snapshot/tree operations
|
|
186
|
+
// The target message may be a non-checkpoint (e.g., assistant response)
|
|
187
|
+
// when called from edit mode. Walk back to find the nearest ancestor checkpoint.
|
|
188
|
+
const checkpointIdSet = new Set(checkpoints.map(c => c.id));
|
|
189
|
+
const resolvedCheckpointId = checkpointIdSet.has(messageId)
|
|
190
|
+
? messageId
|
|
191
|
+
: findCheckpointForHead(messageId, allMessages, checkpointIdSet);
|
|
103
192
|
|
|
104
|
-
|
|
193
|
+
debug.log('snapshot', `Resolved checkpoint: ${resolvedCheckpointId} (target was ${messageId})`);
|
|
194
|
+
|
|
195
|
+
// 4. Find session end (last message of target's session)
|
|
105
196
|
const sessionEnd = findSessionEnd(checkpointMessage, allMessages);
|
|
106
197
|
debug.log('snapshot', `Session end: ${sessionEnd.id}`);
|
|
107
198
|
|
|
@@ -137,12 +228,19 @@ export const restoreHandler = createRouter()
|
|
|
137
228
|
}
|
|
138
229
|
|
|
139
230
|
// 6. Update checkpoint_tree_state for ancestors
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
231
|
+
// Use resolved checkpoint ID (not raw messageId which may be a non-checkpoint)
|
|
232
|
+
// Also compute checkpointPath for branch-aware file restore
|
|
233
|
+
let checkpointPath: string[] = [];
|
|
234
|
+
if (resolvedCheckpointId) {
|
|
235
|
+
checkpointPath = getCheckpointPathToRoot(resolvedCheckpointId, parentMap);
|
|
236
|
+
if (checkpointPath.length > 1) {
|
|
237
|
+
checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
|
|
238
|
+
}
|
|
143
239
|
}
|
|
144
240
|
|
|
145
241
|
// 7. Restore file system state using session-scoped restore
|
|
242
|
+
// Use resolved checkpoint ID so the snapshot lookup matches correctly
|
|
243
|
+
// (snapshots are keyed by checkpoint user message IDs, not assistant messages)
|
|
146
244
|
let filesRestored = 0;
|
|
147
245
|
let filesSkipped = 0;
|
|
148
246
|
|
|
@@ -153,8 +251,9 @@ export const restoreHandler = createRouter()
|
|
|
153
251
|
const result = await snapshotService.restoreSessionScoped(
|
|
154
252
|
project.path,
|
|
155
253
|
sessionId,
|
|
156
|
-
|
|
157
|
-
conflictResolutions
|
|
254
|
+
resolvedCheckpointId,
|
|
255
|
+
conflictResolutions,
|
|
256
|
+
checkpointPath.length > 0 ? checkpointPath : undefined
|
|
158
257
|
);
|
|
159
258
|
filesRestored = result.restoredFiles;
|
|
160
259
|
filesSkipped = result.skippedFiles;
|
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
getCheckpointPathToRoot,
|
|
16
16
|
findCheckpointForHead,
|
|
17
17
|
isDescendant,
|
|
18
|
-
getCheckpointFileStats
|
|
18
|
+
getCheckpointFileStats,
|
|
19
|
+
INITIAL_NODE_ID
|
|
19
20
|
} from '../../lib/snapshot/helpers';
|
|
20
21
|
import type { CheckpointNode, TimelineResponse } from '../../lib/snapshot/helpers';
|
|
21
22
|
import type { SDKMessage } from '$shared/types/messaging';
|
|
@@ -36,11 +37,8 @@ export const timelineHandler = createRouter()
|
|
|
36
37
|
|
|
37
38
|
// 1. Get current HEAD
|
|
38
39
|
const currentHead = sessionQueries.getHead(sessionId);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!currentHead) {
|
|
42
|
-
return { nodes: [], currentHeadId: null };
|
|
43
|
-
}
|
|
40
|
+
const isAtInitialState = !currentHead;
|
|
41
|
+
debug.log('snapshot', `Current HEAD: ${currentHead || 'null (initial state)'}`);
|
|
44
42
|
|
|
45
43
|
// 2. Get all messages
|
|
46
44
|
const allMessages = messageQueries.getAllBySessionId(sessionId);
|
|
@@ -61,8 +59,10 @@ export const timelineHandler = createRouter()
|
|
|
61
59
|
const checkpointIdSet = new Set(checkpoints.map(c => c.id));
|
|
62
60
|
|
|
63
61
|
// 4. Find which checkpoint HEAD belongs to
|
|
64
|
-
const activeCheckpointId =
|
|
65
|
-
|
|
62
|
+
const activeCheckpointId = isAtInitialState
|
|
63
|
+
? null
|
|
64
|
+
: findCheckpointForHead(currentHead, allMessages, checkpointIdSet);
|
|
65
|
+
debug.log('snapshot', `Active checkpoint: ${activeCheckpointId || '(initial)'}`);
|
|
66
66
|
|
|
67
67
|
// 5. Build active path (from root to active checkpoint)
|
|
68
68
|
const activePathIds = new Set<string>();
|
|
@@ -76,22 +76,47 @@ export const timelineHandler = createRouter()
|
|
|
76
76
|
// 6. Get active children map from database
|
|
77
77
|
const activeChildrenMap = checkpointQueries.getAllActiveChildren(sessionId);
|
|
78
78
|
|
|
79
|
-
// 7.
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
79
|
+
// 7. Build response nodes
|
|
80
|
+
const nodes: CheckpointNode[] = [];
|
|
81
|
+
|
|
82
|
+
// Find root checkpoints (those with no parent)
|
|
83
|
+
const rootCheckpointIds = checkpoints
|
|
84
|
+
.filter(cp => !parentMap.has(cp.id))
|
|
85
|
+
.map(cp => cp.id);
|
|
86
|
+
|
|
87
|
+
// Get session started_at for the initial node timestamp
|
|
88
|
+
const session = sessionQueries.getById(sessionId);
|
|
89
|
+
const sessionStartedAt = session?.started_at || new Date().toISOString();
|
|
90
|
+
|
|
91
|
+
// Add the "Initial State" node at the beginning
|
|
92
|
+
// Its activeChildId points to the first root checkpoint on the active path,
|
|
93
|
+
// or the first root checkpoint if we're at initial state
|
|
94
|
+
let initialActiveChildId: string | null = null;
|
|
95
|
+
if (isAtInitialState) {
|
|
96
|
+
// At initial state: the first root checkpoint (by timestamp) is the active child
|
|
97
|
+
initialActiveChildId = rootCheckpointIds[0] || null;
|
|
98
|
+
} else {
|
|
99
|
+
// Find root checkpoint on active path
|
|
100
|
+
initialActiveChildId = rootCheckpointIds.find(id => activePathIds.has(id)) || rootCheckpointIds[0] || null;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
nodes.push({
|
|
104
|
+
id: INITIAL_NODE_ID,
|
|
105
|
+
messageId: INITIAL_NODE_ID,
|
|
106
|
+
parentId: null,
|
|
107
|
+
activeChildId: initialActiveChildId,
|
|
108
|
+
timestamp: sessionStartedAt,
|
|
109
|
+
messageText: 'Session Start',
|
|
110
|
+
isOnActivePath: isAtInitialState || activePathIds.size > 0,
|
|
111
|
+
isOrphaned: false,
|
|
112
|
+
isCurrent: isAtInitialState,
|
|
113
|
+
hasSnapshot: false,
|
|
114
|
+
isInitial: true,
|
|
115
|
+
senderName: null,
|
|
116
|
+
filesChanged: 0,
|
|
117
|
+
insertions: 0,
|
|
118
|
+
deletions: 0
|
|
119
|
+
});
|
|
95
120
|
|
|
96
121
|
for (const cp of checkpoints) {
|
|
97
122
|
const sdk = JSON.parse(cp.sdk_message) as SDKMessage;
|
|
@@ -107,16 +132,16 @@ export const timelineHandler = createRouter()
|
|
|
107
132
|
isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
|
|
108
133
|
}
|
|
109
134
|
|
|
110
|
-
// File stats
|
|
111
|
-
const
|
|
112
|
-
const stats = getCheckpointFileStats(cp, allMessages, nextTimestamp);
|
|
135
|
+
// File stats from checkpoint's own snapshot
|
|
136
|
+
const stats = getCheckpointFileStats(cp);
|
|
113
137
|
|
|
114
138
|
const snapshot = snapshotQueries.getByMessageId(cp.id);
|
|
115
139
|
|
|
116
140
|
nodes.push({
|
|
117
141
|
id: cp.id,
|
|
118
142
|
messageId: cp.id,
|
|
119
|
-
|
|
143
|
+
// Root checkpoints have initial node as parent
|
|
144
|
+
parentId: parentCpId || INITIAL_NODE_ID,
|
|
120
145
|
activeChildId,
|
|
121
146
|
timestamp: cp.timestamp,
|
|
122
147
|
messageText,
|
|
@@ -131,11 +156,13 @@ export const timelineHandler = createRouter()
|
|
|
131
156
|
});
|
|
132
157
|
}
|
|
133
158
|
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
const currentHeadId = isAtInitialState ? INITIAL_NODE_ID : activeCheckpointId;
|
|
160
|
+
|
|
161
|
+
debug.log('snapshot', `Timeline nodes: ${nodes.length} (including initial)`);
|
|
162
|
+
debug.log('snapshot', `Active path: ${activePathIds.size} nodes, current: ${currentHeadId}`);
|
|
136
163
|
|
|
137
164
|
return {
|
|
138
165
|
nodes,
|
|
139
|
-
currentHeadId
|
|
166
|
+
currentHeadId
|
|
140
167
|
};
|
|
141
168
|
});
|