src/vs/platform/agentHost/node/agentHostReviewService.ts

262 LOC · 83 covered · 179 uncovered · 16 ranges · 917 concepts · 2 introducers · 483 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentService.ts ×122
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 { SequencerByKey } from '../../../base/common/async.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { relativePath } from '../../../base/common/resources.js';
9 > import { URI } from '../../../base/common/uri.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import { AgentSession } from '../common/agentService.js';
12 > import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
13 > import { EMPTY_TREE_OBJECT, IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
14 > import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js';
15 > import { ISessionDataService } from '../common/sessionDataService.js';
16 > import { readSessionGitState, type URI as ProtocolURI } from '../common/state/sessionState.js';
17 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
18 >
19 > /**
20 > * Resolved git context shared by the review operations: the repository root,
21 > * the Branch Changes baseline tree, and the current reviewed ref/tree.
22 > */
23 > interface IReviewContext {
24 > readonly repoRoot: URI;
25 > /** Tree object of the baseline. */
26 > readonly baselineTree: string;
27 > /** Name of the session's reviewed ref. */
28 > readonly reviewedRef: string;
29 > /** Current reviewed commit, or `undefined` when the ref does not exist yet. */
30 > readonly reviewedCommit: string | undefined;
31 > /** Current reviewed tree; equals `baselineTree` when the ref does not exist. */
32 > readonly reviewedTree: string;
33 > }
34 >
35 > export class AgentHostReviewService extends Disposable implements IAgentHostReviewService {
36 > declare readonly _serviceBrand: undefined;
37 >
38 > /**
39 > * Serializes mark/unmark/read per session so back-to-back mutations don't
40 > * race on the reviewed ref rebuild and reads observe a consistent ref.
41 > */
42 > private readonly _sequencer = new SequencerByKey<string>();
43 >
44 > constructor(
45 > @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, agentService.ts ×10
46 > @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
47 > @ISessionDataService private readonly _sessionDataService: ISessionDataService,
48 > @ILogService private readonly _logService: ILogService,
49 > ) {
50 > super();
51 >
52 > // When a session's data directory is about to be deleted, delete the
53 > // reviewed ref we created for it. The working directory needed to
54 > // resolve the repository root is supplied by the event (resolved from
55 > // live session state) so we don't persist our own copy.
56 > this._register(this._sessionDataService.onWillDeleteSessionData(e => {
57 e.waitUntil(this.disposeSessionData(e.session.toString()));
59 > }
61 > async setReviewState(channel: ProtocolURI, resources: readonly ProtocolURI[], reviewed: boolean): Promise<void> {
62 const parsed = parseChangesetUri(channel);
63 if (!parsed || parsed.kind !== ChangesetKind.Branch) {
64 throw new Error(`Not a branch changeset URI: ${channel}`);
65 }
66
67 const sessionState = this._stateManager.getSessionState(parsed.sessionUri);
68 if (!sessionState) {
69 throw new Error(`Session not found: ${parsed.sessionUri}`);
70 }
71 if (!sessionState.workingDirectories?.[0]) {
72 throw new Error(`Session has no working directory: ${parsed.sessionUri}`);
73 }
74
75 const databaseRef = this._sessionDataService.openDatabase(URI.parse(parsed.sessionUri));
76 let persistedBaseBranch: string | undefined;
77 try {
78 persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH);
79 } finally {
80 databaseRef.dispose();
81 }
82
83 const workingDirectory = URI.parse(sessionState.workingDirectories?.[0]);
84 const baseBranch = resolveDiffBaseBranchName(persistedBaseBranch, readSessionGitState(sessionState._meta)?.baseBranchName);
85 await this._sequencer.queue(parsed.sessionUri, async () => {
86 for (const resource of resources) {
87 await this._setReviewed(parsed.sessionUri, workingDirectory, baseBranch, URI.parse(resource), reviewed);
88 }
89 });
90 }
92 > markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
93 return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true));
94 }
96 > markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
97 return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, false));
98 }
100 > getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
101 return this._sequencer.queue(session, () => this._getReviewedPaths(session, workingDirectory, baseBranch));
102 }
104 > copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
105 return this._sequencer.queue(targetSession, () => this._copyReviewedRef(sourceSession, targetSession, workingDirectory));
106 }
108 > private async _copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
109 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
110 if (!repoRoot) {
111 return;
112 }
113
114 const sourceRef = buildReviewedRefName(this._sanitizedSessionId(sourceSession));
115 const sourceCommit = await this._gitService.revParse(repoRoot, sourceRef);
116 if (!sourceCommit) {
117 return;
118 }
119
120 const targetRef = buildReviewedRefName(this._sanitizedSessionId(targetSession));
121 await this._gitService.updateRef(repoRoot, targetRef, sourceCommit);
122 this._logService.trace(`[AgentHostReview][_copyReviewedRef] Copied reviewed ref ${sourceRef} -> ${targetRef} for fork`);
123 }
125 > private async _setReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI, reviewed: boolean): Promise<void> {
126 const context = await this._resolveContext(session, workingDirectory, baseBranch);
127 if (!context) {
128 return;
129 }
130
131 const path = relativePath(context.repoRoot, resource);
132 if (!path) {
133 this._logService.warn(`[AgentHostReview][_setReviewed] '${resource.toString()}' is not under the repository root '${context.repoRoot.toString()}'; skipping`);
134 return;
135 }
136
137 // To mark a file reviewed, overlay its current working-tree content into
138 // the reviewed tree; to unmark, reset it to the baseline content.
139 let source: string | undefined;
140 if (reviewed) {
141 source = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
142 } else {
143 source = context.baselineTree;
144 }
145 if (!source) {
146 return;
147 }
148
149 const newTree = await this._gitService.overlayPathIntoTree(context.repoRoot, context.reviewedTree, path, source);
150 if (!newTree) {
151 return;
152 }
153 if (newTree === context.reviewedTree) {
154 // No change (already reviewed / already unreviewed).
155 // Don't grow the reviewed ref chain with a no-op
156 // commit.
157 return;
158 }
159
160 // The reviewed ref is a session-private chain disconnected from the
161 // real git history (mirroring the checkpoint baseline): the first
162 // commit is a parentless root, and subsequent commits chain onto the
163 // prior reviewed commit.
164 const message = `review: ${reviewed ? 'mark' : 'unmark'} ${path}`;
165 const commit = await this._gitService.commitTree(context.repoRoot, newTree, context.reviewedCommit, message);
166 if (!commit) {
167 return;
168 }
169
170 await this._gitService.updateRef(context.repoRoot, context.reviewedRef, commit);
171
172 this._logService.trace(`[AgentHostReview][_setReviewed] ${message} for ${session.toString()} -> ${context.reviewedRef}@${commit}`);
173 }
175 > private async _getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
176 const context = await this._resolveContext(session, workingDirectory, baseBranch);
177 if (!context?.reviewedCommit) {
178 // No reviewed ref yet means
179 // nothing has been reviewed.
180 return new Set();
181 }
182
183 const workingTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
184 if (!workingTree) {
185 return new Set();
186 }
187
188 // Changed = files that differ between the baseline and the working tree
189 // (the Branch Changes universe). Unreviewed = files that still differ
190 // between the reviewed tree and the working tree. Reviewed is the
191 // difference: changed files whose reviewed content already matches the
192 // working tree.
193 const [changed, unreviewed] = await Promise.all([
194 this._gitService.diffTreePaths(context.repoRoot, context.baselineTree, workingTree),
195 this._gitService.diffTreePaths(context.repoRoot, context.reviewedTree, workingTree),
196 ]);
197 if (!changed) {
198 return new Set();
199 }
200
201 const unreviewedSet = new Set(unreviewed ?? []);
202 return new Set(changed.filter(path => !unreviewedSet.has(path)));
203 }
205 > private async _resolveContext(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<IReviewContext | undefined> {
206 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
207 if (!repoRoot) {
208 return undefined;
209 }
210
211 const baselineCommit = await this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch);
212 if (!baselineCommit) {
213 return undefined;
214 }
215
216 const baselineTree = baselineCommit !== EMPTY_TREE_OBJECT
217 ? await this._gitService.revParse(repoRoot, `${baselineCommit}^{tree}`)
218 : EMPTY_TREE_OBJECT;
219 if (!baselineTree) {
220 return undefined;
221 }
222
223 const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session));
224 const reviewedCommit = await this._gitService.revParse(repoRoot, reviewedRef);
225 const reviewedTree = reviewedCommit
226 ? await this._gitService.revParse(repoRoot, `${reviewedCommit}^{tree}`) ?? baselineTree
227 : baselineTree;
228
229 return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree };
230 }
232 > async disposeSessionData(session: ProtocolURI): Promise<void> {
233 await this._sequencer.queue(session, () => this._disposeSessionData(session));
234 }
236 > private async _disposeSessionData(session: ProtocolURI): Promise<void> {
237 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
238 if (!workingDirectory) {
239 // No working directory means we can't resolve the repository root
240 // (session was never git-backed, or its working directory is gone).
241 return;
242 }
243
244 const repoRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory));
245 if (!repoRoot) {
246 return;
247 }
248
249 try {
250 const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session));
251 await this._gitService.deleteRefs(repoRoot, [reviewedRef]);
252
253 this._logService.trace(`[AgentHostReview][_disposeSessionData] Deleted reviewed ref for ${session}`);
254 } catch (err) {
255 this._logService.warn(`[AgentHostReview][_disposeSessionData] Failed to dispose reviewed ref for ${session}`, err);
256 }
257 }
259 > private _sanitizedSessionId(session: ProtocolURI): string {
260 return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-');
261 }