@myrialabs/clopen 0.1.9 β 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/queries/session-queries.ts +13 -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/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- 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/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
- 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
package/backend/index.ts
CHANGED
|
@@ -118,7 +118,11 @@ async function startServer() {
|
|
|
118
118
|
port: actualPort,
|
|
119
119
|
hostname: HOST
|
|
120
120
|
}, () => {
|
|
121
|
-
|
|
121
|
+
if (isDevelopment) {
|
|
122
|
+
console.log('π Backend ready β waiting for frontend...');
|
|
123
|
+
} else {
|
|
124
|
+
console.log(`π Clopen running at http://localhost:${actualPort}`);
|
|
125
|
+
}
|
|
122
126
|
if (HOST === '0.0.0.0') {
|
|
123
127
|
const ips = getLocalIps();
|
|
124
128
|
for (const ip of ips) {
|
|
@@ -891,7 +891,10 @@ class StreamManager extends EventEmitter {
|
|
|
891
891
|
const { projectPath, projectId, chatSessionId } = requestData;
|
|
892
892
|
if (projectPath && projectId && chatSessionId && userMessageId) {
|
|
893
893
|
snapshotService.captureSnapshot(projectPath, projectId, chatSessionId, userMessageId)
|
|
894
|
-
.then(() =>
|
|
894
|
+
.then(() => {
|
|
895
|
+
debug.log('chat', `Stream-end snapshot captured for message: ${userMessageId}`);
|
|
896
|
+
this.emit('snapshot:captured', { projectId, chatSessionId });
|
|
897
|
+
})
|
|
895
898
|
.catch(err => debug.error('chat', 'Failed to capture stream-end snapshot:', err));
|
|
896
899
|
}
|
|
897
900
|
}
|
|
@@ -188,6 +188,19 @@ export const sessionQueries = {
|
|
|
188
188
|
`).run(messageId, sessionId);
|
|
189
189
|
},
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Clear the HEAD pointer (set to NULL).
|
|
193
|
+
* Used when restoring to the initial state (before any messages).
|
|
194
|
+
*/
|
|
195
|
+
clearHead(sessionId: string): void {
|
|
196
|
+
const db = getDatabase();
|
|
197
|
+
db.prepare(`
|
|
198
|
+
UPDATE chat_sessions
|
|
199
|
+
SET current_head_message_id = NULL
|
|
200
|
+
WHERE id = ?
|
|
201
|
+
`).run(sessionId);
|
|
202
|
+
},
|
|
203
|
+
|
|
191
204
|
/**
|
|
192
205
|
* Get current HEAD message ID for a session
|
|
193
206
|
*/
|
|
@@ -114,7 +114,7 @@ export const snapshotQueries = {
|
|
|
114
114
|
const db = getDatabase();
|
|
115
115
|
const snapshots = db.prepare(`
|
|
116
116
|
SELECT * FROM message_snapshots
|
|
117
|
-
WHERE session_id = ?
|
|
117
|
+
WHERE session_id = ? AND (is_deleted IS NULL OR is_deleted = 0)
|
|
118
118
|
ORDER BY created_at ASC
|
|
119
119
|
`).all(sessionId) as MessageSnapshot[];
|
|
120
120
|
|
|
@@ -81,6 +81,14 @@ export function getClient(): OpencodeClient | null {
|
|
|
81
81
|
return ready ? client : null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Get the OpenCode server base URL (e.g. "http://127.0.0.1:4096").
|
|
86
|
+
* Used for direct HTTP calls to v2 endpoints not available on the v1 client.
|
|
87
|
+
*/
|
|
88
|
+
export function getServerUrl(): string | null {
|
|
89
|
+
return serverHandle?.url ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
/**
|
|
85
93
|
* Dispose the OpenCode client and stop the server.
|
|
86
94
|
* Called during full server shutdown (disposeAllEngines).
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
convertSubtaskToolUseOnly,
|
|
41
41
|
getToolInput,
|
|
42
42
|
} from './message-converter';
|
|
43
|
-
import { ensureClient, getClient } from './server';
|
|
43
|
+
import { ensureClient, getClient, getServerUrl } from './server';
|
|
44
44
|
import { debug } from '$shared/utils/logger';
|
|
45
45
|
|
|
46
46
|
/** Map SDK Model.status to our category */
|
|
@@ -75,6 +75,8 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
75
75
|
private activeAbortController: AbortController | null = null;
|
|
76
76
|
private activeSessionId: string | null = null;
|
|
77
77
|
private activeProjectPath: string | null = null;
|
|
78
|
+
/** Pending question requests keyed by tool callID β { requestId, questions } */
|
|
79
|
+
private pendingQuestions = new Map<string, { requestId: string; questions: Array<{ question: string }> }>();
|
|
78
80
|
|
|
79
81
|
get isInitialized(): boolean {
|
|
80
82
|
return this._isInitialized;
|
|
@@ -102,6 +104,7 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
102
104
|
*/
|
|
103
105
|
async dispose(): Promise<void> {
|
|
104
106
|
await this.cancel();
|
|
107
|
+
this.pendingQuestions.clear();
|
|
105
108
|
this._isInitialized = false;
|
|
106
109
|
debug.log('engine', 'Open Code engine instance disposed');
|
|
107
110
|
}
|
|
@@ -664,6 +667,39 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
664
667
|
break;
|
|
665
668
|
}
|
|
666
669
|
|
|
670
|
+
// v2 question event β emitted when the question tool needs user input
|
|
671
|
+
case 'question.asked': {
|
|
672
|
+
const props = evt.properties as {
|
|
673
|
+
id: string;
|
|
674
|
+
sessionID: string;
|
|
675
|
+
questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }> }>;
|
|
676
|
+
tool?: { messageID: string; callID: string };
|
|
677
|
+
};
|
|
678
|
+
if (props.sessionID !== sessionId) break;
|
|
679
|
+
if (props.tool?.callID) {
|
|
680
|
+
this.pendingQuestions.set(props.tool.callID, {
|
|
681
|
+
requestId: props.id,
|
|
682
|
+
questions: props.questions,
|
|
683
|
+
});
|
|
684
|
+
debug.log('engine', `[OC] question.asked: stored question ${props.id} for callID ${props.tool.callID}`);
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// v2 permission event β auto-approve to avoid blocking the session
|
|
690
|
+
// (tool permissions like file_write, bash, etc. are bypassed)
|
|
691
|
+
case 'permission.asked':
|
|
692
|
+
case 'permission.updated': {
|
|
693
|
+
const props = evt.properties as {
|
|
694
|
+
id: string;
|
|
695
|
+
sessionID: string;
|
|
696
|
+
callID?: string;
|
|
697
|
+
};
|
|
698
|
+
if (props.sessionID !== sessionId) break;
|
|
699
|
+
this.autoApprovePermission(props.id, props.sessionID);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
|
|
667
703
|
case 'session.error': {
|
|
668
704
|
const errorProps = (event as EventSessionError).properties;
|
|
669
705
|
// Only handle errors for our session (sessionID is optional on errors)
|
|
@@ -729,6 +765,7 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
729
765
|
this.activeAbortController = null;
|
|
730
766
|
this.activeSessionId = null;
|
|
731
767
|
this.activeProjectPath = null;
|
|
768
|
+
this.pendingQuestions.clear();
|
|
732
769
|
}
|
|
733
770
|
}
|
|
734
771
|
|
|
@@ -754,6 +791,7 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
754
791
|
this._isActive = false;
|
|
755
792
|
this.activeSessionId = null;
|
|
756
793
|
this.activeProjectPath = null;
|
|
794
|
+
this.pendingQuestions.clear();
|
|
757
795
|
}
|
|
758
796
|
|
|
759
797
|
/**
|
|
@@ -779,6 +817,142 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
779
817
|
await this.cancel();
|
|
780
818
|
}
|
|
781
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Resolve a pending AskUserQuestion by replying via the OpenCode question API.
|
|
822
|
+
*
|
|
823
|
+
* Flow:
|
|
824
|
+
* 1. If a `question.asked` event was received β use stored requestId to reply
|
|
825
|
+
* 2. Fallback β fetch pending questions from GET /question and match by callID
|
|
826
|
+
*
|
|
827
|
+
* The reply is sent to POST /question/{requestID}/reply with answers
|
|
828
|
+
* ordered by the original questions array.
|
|
829
|
+
*/
|
|
830
|
+
resolveUserAnswer(toolUseId: string, answers: Record<string, string>): boolean {
|
|
831
|
+
const pending = this.pendingQuestions.get(toolUseId);
|
|
832
|
+
|
|
833
|
+
if (pending) {
|
|
834
|
+
// Convert Record<questionText, answerLabel> β Array<Array<string>> ordered by questions
|
|
835
|
+
const orderedAnswers = pending.questions.map(q => {
|
|
836
|
+
const answer = answers[q.question];
|
|
837
|
+
return answer ? [answer] : [];
|
|
838
|
+
});
|
|
839
|
+
this.replyToQuestion(pending.requestId, orderedAnswers);
|
|
840
|
+
this.pendingQuestions.delete(toolUseId);
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Fallback: fetch pending questions from the API and find matching one
|
|
845
|
+
debug.log('engine', `resolveUserAnswer: No stored question for toolUseId ${toolUseId}, fetching from API...`);
|
|
846
|
+
this.fetchAndReplyToQuestion(toolUseId, answers);
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* POST /question/{requestID}/reply to send user answers back to the OpenCode server.
|
|
852
|
+
*/
|
|
853
|
+
private replyToQuestion(requestId: string, orderedAnswers: string[][]): void {
|
|
854
|
+
const serverUrl = getServerUrl();
|
|
855
|
+
if (!serverUrl) {
|
|
856
|
+
debug.warn('engine', 'replyToQuestion: Server URL not available');
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
|
|
861
|
+
const url = `${serverUrl}/question/${requestId}/reply${dirParam}`;
|
|
862
|
+
debug.log('engine', `Replying to question ${requestId}:`, orderedAnswers);
|
|
863
|
+
|
|
864
|
+
fetch(url, {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
headers: { 'Content-Type': 'application/json' },
|
|
867
|
+
body: JSON.stringify({ answers: orderedAnswers }),
|
|
868
|
+
}).then(async res => {
|
|
869
|
+
if (res.ok) {
|
|
870
|
+
debug.log('engine', `Question reply accepted: ${requestId} (${res.status})`);
|
|
871
|
+
} else {
|
|
872
|
+
const body = await res.text().catch(() => '');
|
|
873
|
+
debug.error('engine', `Question reply failed: ${res.status} ${res.statusText}`, body);
|
|
874
|
+
}
|
|
875
|
+
}).catch(error => {
|
|
876
|
+
debug.error('engine', 'Failed to reply to question:', error);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Fallback: GET /question to list pending questions, find the matching one, and reply.
|
|
882
|
+
*/
|
|
883
|
+
private async fetchAndReplyToQuestion(toolUseId: string, answers: Record<string, string>): Promise<void> {
|
|
884
|
+
const serverUrl = getServerUrl();
|
|
885
|
+
if (!serverUrl) {
|
|
886
|
+
debug.warn('engine', 'fetchAndReplyToQuestion: Server URL not available');
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
try {
|
|
891
|
+
const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
|
|
892
|
+
const res = await fetch(`${serverUrl}/question${dirParam}`);
|
|
893
|
+
if (!res.ok) {
|
|
894
|
+
debug.error('engine', `Failed to list pending questions: ${res.status}`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const questions = await res.json() as Array<{
|
|
899
|
+
id: string;
|
|
900
|
+
questions: Array<{ question: string }>;
|
|
901
|
+
tool?: { callID: string };
|
|
902
|
+
}>;
|
|
903
|
+
|
|
904
|
+
const matching = questions.find(q => q.tool?.callID === toolUseId);
|
|
905
|
+
if (!matching) {
|
|
906
|
+
debug.warn('engine', 'fetchAndReplyToQuestion: No matching question for toolUseId:', toolUseId);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const orderedAnswers = matching.questions.map(q => {
|
|
911
|
+
const answer = answers[q.question];
|
|
912
|
+
return answer ? [answer] : [];
|
|
913
|
+
});
|
|
914
|
+
this.replyToQuestion(matching.id, orderedAnswers);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
debug.error('engine', 'Failed to fetch and reply to question:', error);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Auto-approve a permission request to avoid blocking the session.
|
|
922
|
+
* Uses direct HTTP since the v1 client may not have the v2 permission.reply method.
|
|
923
|
+
*/
|
|
924
|
+
private autoApprovePermission(permissionId: string, sessionId: string): void {
|
|
925
|
+
const serverUrl = getServerUrl();
|
|
926
|
+
if (!serverUrl) return;
|
|
927
|
+
|
|
928
|
+
// Try v2 endpoint first (/permission/{requestID}/reply), fall back to v1
|
|
929
|
+
fetch(`${serverUrl}/permission/${permissionId}/reply`, {
|
|
930
|
+
method: 'POST',
|
|
931
|
+
headers: { 'Content-Type': 'application/json' },
|
|
932
|
+
body: JSON.stringify({ reply: 'once' }),
|
|
933
|
+
}).then(res => {
|
|
934
|
+
if (res.ok) {
|
|
935
|
+
debug.log('engine', `[OC] auto-approved permission ${permissionId} (v2)`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// v2 endpoint not available β try v1
|
|
939
|
+
const client = getClient();
|
|
940
|
+
if (client) {
|
|
941
|
+
client.postSessionIdPermissionsPermissionId({
|
|
942
|
+
path: { id: sessionId, permissionID: permissionId },
|
|
943
|
+
body: { response: 'once' },
|
|
944
|
+
...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
|
|
945
|
+
}).then(() => {
|
|
946
|
+
debug.log('engine', `[OC] auto-approved permission ${permissionId} (v1)`);
|
|
947
|
+
}).catch(err => {
|
|
948
|
+
debug.error('engine', 'Failed to auto-approve permission (v1):', err);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}).catch(error => {
|
|
952
|
+
debug.error('engine', 'Failed to auto-approve permission:', error);
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
782
956
|
/**
|
|
783
957
|
* Extract prompt parts (text + file attachments) from SDKUserMessage.
|
|
784
958
|
* Converts Claude-format image/document blocks to OpenCode FilePartInput format.
|
|
@@ -7,6 +7,9 @@ import type { DatabaseMessage } from '$shared/types/database/schema';
|
|
|
7
7
|
* Snapshot domain helper functions
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/** Sentinel ID for the "initial state" node (before any chat messages) */
|
|
11
|
+
export const INITIAL_NODE_ID = '__initial__';
|
|
12
|
+
|
|
10
13
|
export interface CheckpointNode {
|
|
11
14
|
id: string;
|
|
12
15
|
messageId: string;
|
|
@@ -18,6 +21,7 @@ export interface CheckpointNode {
|
|
|
18
21
|
isOrphaned: boolean; // descendant of current active checkpoint
|
|
19
22
|
isCurrent: boolean; // this is the current active checkpoint
|
|
20
23
|
hasSnapshot: boolean;
|
|
24
|
+
isInitial?: boolean; // true for the "initial state" node
|
|
21
25
|
senderName?: string | null;
|
|
22
26
|
// File change statistics (git-like)
|
|
23
27
|
filesChanged?: number;
|
|
@@ -335,62 +339,31 @@ export function isDescendant(
|
|
|
335
339
|
}
|
|
336
340
|
|
|
337
341
|
/**
|
|
338
|
-
* Get file change stats for a checkpoint
|
|
339
|
-
*
|
|
342
|
+
* Get file change stats for a checkpoint.
|
|
343
|
+
* The snapshot associated with the checkpoint message itself contains the stats
|
|
344
|
+
* (file changes the assistant made in response to this user message).
|
|
340
345
|
*/
|
|
341
346
|
export function getCheckpointFileStats(
|
|
342
|
-
checkpointMsg: DatabaseMessage
|
|
343
|
-
allMessages: DatabaseMessage[],
|
|
344
|
-
nextCheckpointTimestamp?: string
|
|
347
|
+
checkpointMsg: DatabaseMessage
|
|
345
348
|
): { filesChanged: number; insertions: number; deletions: number } {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const checkpointTimestamp = checkpointMsg.timestamp;
|
|
351
|
-
|
|
352
|
-
const laterMessages = allMessages
|
|
353
|
-
.filter(m => {
|
|
354
|
-
if (m.timestamp <= checkpointTimestamp) return false;
|
|
355
|
-
if (nextCheckpointTimestamp && m.timestamp >= nextCheckpointTimestamp) return false;
|
|
356
|
-
return true;
|
|
357
|
-
})
|
|
358
|
-
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
349
|
+
const snapshot = snapshotQueries.getByMessageId(checkpointMsg.id);
|
|
350
|
+
if (!snapshot) {
|
|
351
|
+
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
352
|
+
}
|
|
359
353
|
|
|
360
|
-
|
|
361
|
-
const
|
|
354
|
+
let filesChanged = snapshot.files_changed || 0;
|
|
355
|
+
const insertions = snapshot.insertions || 0;
|
|
356
|
+
const deletions = snapshot.deletions || 0;
|
|
362
357
|
|
|
363
|
-
|
|
358
|
+
// Try to get a more accurate file count from session_changes
|
|
359
|
+
if (snapshot.session_changes) {
|
|
364
360
|
try {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (!userSnapshot) continue;
|
|
370
|
-
|
|
371
|
-
const fc = userSnapshot.files_changed || 0;
|
|
372
|
-
const ins = userSnapshot.insertions || 0;
|
|
373
|
-
const del = userSnapshot.deletions || 0;
|
|
374
|
-
|
|
375
|
-
if (fc > 0 || ins > 0 || del > 0) {
|
|
376
|
-
statsInRange.push({ files: fc, ins, del });
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (userSnapshot.delta_changes) {
|
|
380
|
-
try {
|
|
381
|
-
const delta = JSON.parse(userSnapshot.delta_changes);
|
|
382
|
-
if (delta.added) Object.keys(delta.added).forEach(f => allChangedFiles.add(f));
|
|
383
|
-
if (delta.modified) Object.keys(delta.modified).forEach(f => allChangedFiles.add(f));
|
|
384
|
-
if (delta.deleted && Array.isArray(delta.deleted)) delta.deleted.forEach((f: string) => allChangedFiles.add(f));
|
|
385
|
-
} catch { /* skip */ }
|
|
361
|
+
const changes = JSON.parse(snapshot.session_changes as string);
|
|
362
|
+
const changeCount = Object.keys(changes).length;
|
|
363
|
+
if (changeCount > 0) {
|
|
364
|
+
filesChanged = changeCount;
|
|
386
365
|
}
|
|
387
|
-
} catch { /*
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (statsInRange.length > 0) {
|
|
391
|
-
filesChanged = allChangedFiles.size > 0 ? allChangedFiles.size : Math.max(...statsInRange.map(s => s.files));
|
|
392
|
-
insertions = statsInRange.reduce((sum, s) => sum + s.ins, 0);
|
|
393
|
-
deletions = statsInRange.reduce((sum, s) => sum + s.del, 0);
|
|
366
|
+
} catch { /* use files_changed from DB */ }
|
|
394
367
|
}
|
|
395
368
|
|
|
396
369
|
return { filesChanged, insertions, deletions };
|
|
@@ -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
|
*/
|