1
>
/*---------------------------------------------------------------------------------------------
sessionDataService.ts
2
>
* Copyright (c) Microsoft Corporation. All rights reserved.
3
>
* Licensed under the MIT License. See License.txt in the project root for license information.
4
>
*--------------------------------------------------------------------------------------------*/
5
>
6
>
import { IDisposable, IReference } from '../../../base/common/lifecycle.js';
7
>
import { URI } from '../../../base/common/uri.js';
8
>
import { createDecorator } from '../../instantiation/common/instantiation.js';
9
>
import { Event } from '../../../base/common/event.js';
10
>
import type { FileEditKind, Message } from './state/sessionState.js';
11
>
12
>
export const ISessionDataService = createDecorator<ISessionDataService>('sessionDataService');
13
>
14
>
/** Filename of the per-session SQLite database. */
15
>
export const SESSION_DB_FILENAME = 'session.db';
16
>
17
>
/**
18
>
* Subdirectory under a session's data directory that holds snapshotted
19
>
* user-message attachments (e.g. pasted images, fetched file references).
20
>
* The agent host writes these on dispatch so large blobs stay out of the
21
>
* in-memory state tree, and reads of files under this directory are
22
>
* auto-approved by the agent's permission flow.
23
>
*/
24
>
export const SESSION_ATTACHMENTS_DIRNAME = 'attachments';
25
>
26
>
// ---- File-edit types ----------------------------------------------------
27
>
28
>
/**
29
>
* Lightweight metadata for a file edit. Returned by {@link ISessionDatabase.getFileEdits}
30
>
* without the (potentially large) file content blobs.
31
>
*/
32
>
export interface IFileEditRecord {
33
>
/** The turn that owns this file edit. */
34
>
turnId: string;
35
>
/** The tool call that produced this edit. */
36
>
toolCallId: string;
37
>
/** Primary file path (after-path for edits/creates/renames, before-path for deletes). */
38
>
filePath: string;
39
>
/** The kind of file operation. */
40
>
kind: FileEditKind;
41
>
/** For renames, the original file path before the move. */
42
>
originalPath?: string;
43
>
/** Number of lines added (informational, for diff metadata). */
44
>
addedLines: number | undefined;
45
>
/** Number of lines removed (informational, for diff metadata). */
46
>
removedLines: number | undefined;
47
>
}
48
>
49
>
/**
50
>
* The before/after content blobs for a single file edit.
51
>
* Retrieved on demand via {@link ISessionDatabase.readFileEditContent}.
52
>
*
53
>
* For creates, `beforeContent` is absent.
54
>
* For deletes, `afterContent` is absent.
55
>
*/
56
>
export interface IFileEditContent {
57
>
/** File content before the edit. Absent for file creations. */
58
>
beforeContent?: Uint8Array;
59
>
/** File content after the edit. Absent for file deletions. */
60
>
afterContent?: Uint8Array;
61
>
}
62
>
63
>
// ---- Reviewed-file types ------------------------------------------------
64
>
65
>
/**
66
>
* A record of a file having been reviewed by the user at a specific content
67
>
* nonce. Returned by {@link ISessionDatabase.getReviewedFiles} and
68
>
* {@link ISessionDatabase.getReviewedFilesForUri}.
69
>
*/
70
>
export interface IReviewedFileRecord {
71
>
/** The reviewed file. */
72
>
uri: URI;
73
>
/** Content version/hash captured at review time. */
74
>
nonce: string;
75
>
}
76
>
77
>
// ---- Session database ---------------------------------------------------
78
>
79
>
/**
80
>
* A host-injected ("local") turn: a completed protocol `Turn` the agent SDK
81
>
* never saw — e.g. the `/rename` acknowledgement or a `!command` terminal run.
82
>
* These are persisted separately from SDK turns so they survive reload, and are
83
>
* interleaved back into the SDK-derived turns on restore.
84
>
*/
85
>
export interface ILocalTurnRecord {
86
>
/** The local turn's id (matches the payload `Turn.id`). */
87
>
turnId: string;
88
>
/** The chat this local turn belongs to (its channel URI string). */
89
>
chatUri: string;
90
>
/**
91
>
* Id of the preceding concrete (SDK-backed) turn this local turn is
92
>
* anchored after, or `undefined` when it precedes any real turn.
93
>
*/
94
>
anchorTurnId: string | undefined;
95
>
/** Monotonic ordering among local turns (used to interleave on restore). */
96
>
seq: number;
97
>
/** JSON-serialized protocol `Turn`. */
98
>
payload: string;
99
>
}
100
>
101
>
102
>
/**
103
>
* A disposable handle to a per-session SQLite database backed by
104
>
* `@vscode/sqlite3`.
105
>
*
106
>
* Callers obtain an instance via {@link ISessionDataService.openDatabase} and
107
>
* **must** dispose it when finished to close the underlying database connection.
108
>
*/
109
>
export interface ISessionDatabase extends IDisposable {
110
>
/**
111
>
* Create a turn record. Must be called before storing file edits that
112
>
* reference this turn.
113
>
*/
114
>
createTurn(turnId: string): Promise<void>;
115
>
116
>
/**
117
>
* Delete a turn and all of its associated file edits (cascade).
118
>
*/
119
>
deleteTurn(turnId: string): Promise<void>;
120
>
121
>
/**
122
>
* Associates a Copilot SDK event ID with a turn. The event ID corresponds
123
>
* to the `user.message` event in the SDK event stream and is used by
124
>
* the SDK's `history.truncate` and `sessions.fork` RPCs.
125
>
*/
126
>
setTurnEventId(turnId: string, eventId: string): Promise<void>;
127
>
128
>
/**
129
>
* Retrieves the SDK event ID previously stored for a turn.
130
>
* Returns `undefined` if no event ID has been set.
131
>
*/
132
>
getTurnEventId(turnId: string): Promise<string | undefined>;
133
>
134
>
/**
135
>
* Returns the SDK event ID of the turn inserted immediately after the
136
>
* given turn, or `undefined` if the given turn is the last one.
137
>
*/
138
>
getNextTurnEventId(turnId: string): Promise<string | undefined>;
139
>
140
>
/**
141
>
* Returns the SDK event ID of the earliest turn in insertion order,
142
>
* or `undefined` if there are no turns.
143
>
*/
144
>
getFirstTurnEventId(): Promise<string | undefined>;
145
>
146
>
/**
147
>
* Associates a git checkpoint ref (e.g. `refs/agents/<sid>/checkpoints/turn/N`)
148
>
* with a turn. Idempotent — last writer wins per turn.
149
>
*/
150
>
setTurnCheckpointRef(turnId: string, ref: string): Promise<void>;
151
>
152
>
/**
153
>
* Retrieves the checkpoint ref previously stored for a turn, or
154
>
* `undefined` if none.
155
>
*/
156
>
getTurnCheckpointRef(turnId: string): Promise<string | undefined>;
157
>
158
>
/**
159
>
* Returns the checkpoint ref of the most recent turn (in insertion
160
>
* order) prior to `turnId` that has a non-null `checkpoint_ref`.
161
>
* Used to resolve the parent checkpoint for end-of-turn diffs without
162
>
* persisting an explicit parent column.
163
>
*/
164
>
getPreviousCheckpointRef(turnId: string): Promise<string | undefined>;
165
>
166
>
/**
167
>
* Returns every non-null `checkpoint_ref` recorded against any turn in
168
>
* this session. Used by checkpoint cleanup to enumerate refs precisely
169
>
* (rather than scanning `for-each-ref` on the underlying repo).
170
>
*/
171
>
getAllCheckpointRefs(): Promise<string[]>;
172
>
173
>
/**
174
>
* Deletes the given turn and all turns inserted after it, along
175
>
* with their associated file edits (cascade).
176
>
*/
177
>
truncateFromTurn(turnId: string): Promise<void>;
178
>
179
>
/**
180
>
* Deletes all turns inserted after the given turn (but keeps the
181
>
* given turn itself). Associated file edits cascade-delete.
182
>
*/
183
>
deleteTurnsAfter(turnId: string): Promise<void>;
184
>
185
>
/**
186
>
* Deletes all turns and their associated file edits.
187
>
*/
188
>
deleteAllTurns(): Promise<void>;
189
>
190
>
// ---- Local (host-injected) turns -------------------------------------
191
>
192
>
/**
193
>
* Persist a host-injected local turn (e.g. `/rename` or `!command`).
194
>
* Replaces any existing record with the same `turnId`.
195
>
*/
196
>
insertLocalTurn(record: ILocalTurnRecord): Promise<void>;
197
>
198
>
/**
199
>
* Retrieve all persisted local turns in this session, in `seq` order.
200
>
* Callers filter by {@link ILocalTurnRecord.chatUri} for a given chat.
201
>
*/
202
>
getLocalTurns(): Promise<ILocalTurnRecord[]>;
203
>
204
>
/**
205
>
* Delete the local turns with the given ids. Ids not present are ignored.
206
>
*/
207
>
deleteLocalTurns(turnIds: readonly string[]): Promise<void>;
208
>
209
>
/**
210
>
* Store a file-edit snapshot (metadata + content) for a tool invocation
211
>
* within a turn.
212
>
*
213
>
* If a record for the same `toolCallId` and `filePath` already exists
214
>
* it is replaced.
215
>
*/
216
>
storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void>;
217
>
218
>
/**
219
>
* Retrieve file-edit metadata for the given tool call IDs.
220
>
* Content blobs are **not** included — use {@link readFileEditContent}
221
>
* to fetch them on demand. Results are returned in insertion order.
222
>
*/
223
>
getFileEdits(toolCallIds: string[]): Promise<IFileEditRecord[]>;
224
>
225
>
/**
226
>
* Retrieve file-edit metadata for all edits in this session.
227
>
* Content blobs are **not** included — use {@link readFileEditContent}
228
>
* to fetch them on demand. Results are returned in insertion order.
229
>
*/
230
>
getAllFileEdits(): Promise<IFileEditRecord[]>;
231
>
232
>
/**
233
>
* Retrieve file-edit metadata for all edits belonging to a specific turn.
234
>
* Content blobs are **not** included — use {@link readFileEditContent}
235
>
* to fetch them on demand. Results are returned in insertion order.
236
>
*/
237
>
getFileEditsByTurn(turnId: string): Promise<IFileEditRecord[]>;
238
>
239
>
/**
240
>
* Read the before/after content blobs for a single file edit.
241
>
* Returns `undefined` if no edit exists for the given key.
242
>
*/
243
>
readFileEditContent(toolCallId: string, filePath: string): Promise<IFileEditContent | undefined>;
244
>
245
>
// ---- Session metadata ------------------------------------------------
246
>
247
>
/**
248
>
* Read a metadata value by key.
249
>
* Returns `undefined` if no value has been stored for the key.
250
>
*/
251
>
getMetadata(key: string): Promise<string | undefined>;
252
>
253
>
/**
254
>
* Gets a bulk of metadata. For example `getMetadataObject({ foo: true }) -> { foo: 'data' }`
255
>
*/
256
>
getMetadataObject<T extends Record<string, unknown>>(obj: T): Promise<{ [K in keyof T]: string | undefined }>;
257
>
258
>
/**
259
>
* Store a metadata key-value pair. Overwrites any existing value for the key.
260
>
*/
261
>
setMetadata(key: string, value: string): Promise<void>;
262
>
263
>
/**
264
>
* Store or clear the draft for a chat in this session.
265
>
*/
266
>
setChatDraft(chat: URI, draft: Message | undefined): Promise<void>;
267
>
268
>
/**
269
>
* Read the stored draft for a chat in this session.
270
>
*/
271
>
getChatDraft(chat: URI): Promise<Message | undefined>;
272
>
273
>
/**
274
>
* Bulk-remaps turn IDs using the provided old→new mapping.
275
>
* Used after copying a database file for a forked session.
276
>
*/
277
>
remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void>;
278
>
279
>
// ---- Reviewed files --------------------------------------------------
280
>
281
>
/**
282
>
* Mark a file (identified by URI + content nonce) as reviewed by the user.
283
>
* Idempotent — re-marking the same `(uri, nonce)` pair is a no-op.
284
>
*/
285
>
markFileReviewed(uri: URI, nonce: string): Promise<void>;
286
>
287
>
/**
288
>
* Remove the reviewed-file entry for the given URI + content nonce.
289
>
* No-op if no such entry exists.
290
>
*/
291
>
unmarkFileReviewed(uri: URI, nonce: string): Promise<void>;
292
>
293
>
/**
294
>
* Return every reviewed-file entry in this session, in insertion order.
295
>
*/
296
>
getReviewedFiles(): Promise<IReviewedFileRecord[]>;
297
>
298
>
/**
299
>
* Return all reviewed-file entries for a specific URI (one per reviewed
300
>
* content nonce), in insertion order.
301
>
*/
302
>
getReviewedFilesForUri(uri: URI): Promise<IReviewedFileRecord[]>;
303
>
304
>
/**
305
>
* Return whether the given file has been reviewed at the given content nonce.
306
>
*/
307
>
isFileReviewed(uri: URI, nonce: string): Promise<boolean>;
308
>
309
>
/**
310
>
* Creates a safe, consistent copy of the database at the given path
311
>
* using SQLite's `VACUUM INTO` command.
312
>
*/
313
>
vacuumInto(targetPath: string): Promise<void>;
314
>
315
>
/**
316
>
* Resolves once all in-flight write operations on this database have
317
>
* settled. Used by graceful shutdown to flush fire-and-forget writes
318
>
* before the process exits.
319
>
*/
320
>
whenIdle(): Promise<void>;
321
>
322
>
/**
323
>
* Close the database connection. After calling this method, the object is
324
>
* considered disposed and all other methods will reject with an error.
325
>
*/
326
>
close(): Promise<void>;
327
>
}
328
>
329
>
/**
330
>
* Provides persistent, per-session data directories on disk.
331
>
*
332
>
* Each session gets a directory under `{userDataPath}/agentSessionData/{sessionId}/`
333
>
* where internal agent-host code can store arbitrary files (e.g. file snapshots).
334
>
*
335
>
* Directories are created lazily — callers should use {@link IFileService.createFolder}
336
>
* before writing files. Cleanup happens eagerly on session removal and via startup
337
>
* garbage collection for orphaned directories.
338
>
*/
339
>
export interface ISessionDataService {
340
>
readonly _serviceBrand: undefined;
341
>
342
>
/**
343
>
* Returns the root data directory URI for a session.
344
>
* Does **not** create the directory on disk; callers use
345
>
* `IFileService.createFolder()` as needed.
346
>
*/
347
>
getSessionDataDir(session: URI): URI;
348
>
349
>
/**
350
>
* Returns the root data directory URI for a session given its raw ID.
351
>
* Equivalent to {@link getSessionDataDir} but without requiring a full URI.
352
>
*/
353
>
getSessionDataDirById(sessionId: string): URI;
354
>
355
>
/**
356
>
* Opens (or creates) a per-session SQLite database. The database file is
357
>
* stored at `{sessionDataDir}/session.db`. Migrations are applied
358
>
* automatically on first use.
359
>
*
360
>
* Returns a ref-counted reference. Multiple callers for the same session
361
>
* share the same underlying connection. The connection is closed when
362
>
* the last reference is disposed.
363
>
*/
364
>
openDatabase(session: URI): IReference<ISessionDatabase>;
365
>
366
>
/**
367
>
* Opens an existing per-session database **only if the database file
368
>
* already exists on disk**. Returns `undefined` when no database has
369
>
* been created yet, avoiding the side effect of materializing empty
370
>
* database files during read-only operations like listing sessions.
371
>
*/
372
>
tryOpenDatabase(session: URI): Promise<IReference<ISessionDatabase> | undefined>;
373
>
374
>
/**
375
>
* Recursively deletes the data directory for a session, if it exists.
376
>
*/
377
>
deleteSessionData(session: URI): Promise<void>;
378
>
379
>
/**
380
>
* Fires immediately before a session's data directory (and the
381
>
* SQLite database within it) is deleted by {@link deleteSessionData}.
382
>
*
383
>
* Subscribers can register asynchronous cleanup work via
384
>
* {@link IWillDeleteSessionDataEvent.waitUntil}; the deletion is
385
>
* blocked until all registered promises settle. Used by
386
>
* `IAgentHostCheckpointService.disposeSessionData` to read the exact
387
>
* list of checkpoint refs from the (still-readable) database and
388
>
* delete them before the directory is removed.
389
>
*
390
>
* Subscribers must own their own error handling — exceptions
391
>
* propagated out of `waitUntil` promises are logged and ignored;
392
>
* deletion proceeds regardless.
393
>
*/
394
>
readonly onWillDeleteSessionData: Event<IWillDeleteSessionDataEvent>;
395
>
396
>
/**
397
>
* Deletes data directories that do not correspond to any known session.
398
>
* Called at startup; safe to call multiple times.
399
>
*/
400
>
cleanupOrphanedData(knownSessionIds: Set<string>): Promise<void>;
401
>
402
>
/**
403
>
* Resolves once all in-flight write operations across every currently
404
>
* open per-session database have settled. Intended for graceful
405
>
* shutdown — fire-and-forget writes (e.g. metadata persistence) would
406
>
* otherwise be lost when the process exits.
407
>
*/
408
>
whenIdle(): Promise<void>;
409
>
}
410
>
411
>
/**
412
>
* Payload of {@link ISessionDataService.onWillDeleteSessionData}.
413
>
*/
414
>
export interface IWillDeleteSessionDataEvent {
415
>
readonly session: URI;
416
>
/**
417
>
* Register an asynchronous task that must settle before the session's
418
>
* data directory is removed.
419
>
*/
420
>
waitUntil(promise: Promise<unknown>): void;
421
>
}