@feng0531/dsh-vcs 0.4.9 → 0.5.1

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/index.mjs CHANGED
@@ -3,8 +3,10 @@ import {readFileSync} from 'node:fs';
3
3
  import {registerFrontendAssets} from './frontend-assets.mjs';
4
4
  const runningFrontend=JSON.parse(readFileSync(new URL('./dist/frontend.json',import.meta.url),'utf8'));
5
5
  import * as adapter from './vcs.mjs';
6
+ import {withOperationBudget} from './src/operation-budget.mjs';
6
7
  import {runtimeInfo,checkUpdate} from './dist/updates.mjs';
7
8
  import {createSvnHttpsHost} from './dist/svn-host.mjs';
9
+ import {historySearchArgs,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
8
10
 
9
11
  export const name = 'dsh-vcs';
10
12
  export const inject = ['connection', 'webServer', 'sessions', 'sessionPersistence'];
@@ -27,8 +29,6 @@ export async function resolveSessionCwd(ctx, sessionId, signal) {
27
29
 
28
30
  const rediscover = () => Object.assign(new Error('Repository authorization is missing, expired, or changed. Rediscover repositories for this Session and retry.'), { code: 'vcs/rediscover-required' });
29
31
 
30
- import {historySearchArgs,validateDateBound} from './git-history.mjs';
31
-
32
32
  // Exact request fields are a protocol contract, independent of dispatch.
33
33
  const REQUEST_FIELDS = {
34
34
  'vcs/repositories': ['sessionId', 'subdirectory'],
@@ -48,57 +48,165 @@ const REQUEST_FIELDS = {
48
48
  'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'large'],
49
49
  'vcs/revision-image': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'side'],
50
50
  'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
51
+ 'vcs/tree-page': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'query'],
51
52
  'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
52
53
  'vcs/tree-segment': ['sessionId', 'repositoryId', 'commit', 'path', 'offset'],
53
54
  'vcs/tree-history': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'follow', 'since', 'until'],
54
55
  'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
55
56
  'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
56
57
  };
58
+
59
+ /** The supported endpoint list and each field whitelist derive from REQUEST_FIELDS. */
60
+ const ACTION_ENDPOINTS = new Set(Object.keys(REQUEST_FIELDS));
61
+ const REQUEST_FIELD_SETS = new Map(Object.entries(REQUEST_FIELDS).map(([endpoint, fields]) => [endpoint, new Set(fields)]));
62
+ const UPDATE_ENDPOINTS = new Set(['vcs/runtime-info', 'vcs/check-update']);
63
+
64
+ // Only repositories, SVN identity, workspace status and workspace comparison can
65
+ // address SVN working copies; every other action reads immutable Git history.
66
+ const SVN_CAPABLE_ENDPOINTS = new Set(['vcs/repositories', 'vcs/svn-identity', 'vcs/status', 'vcs/compare']);
67
+ const GIT_ONLY_ENDPOINTS = new Set([...ACTION_ENDPOINTS].filter(endpoint => !SVN_CAPABLE_ENDPOINTS.has(endpoint)));
68
+
69
+ // Named endpoint groups keep every request-shape and capacity decision in one place.
70
+ const HISTORICAL_PATH_ENDPOINTS = new Set(['vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history']);
71
+ const REVISION_PAIR_ENDPOINTS = new Set(['vcs/revision-changes', 'vcs/revision-compare', 'vcs/revision-image']);
72
+ const REVISION_CHANGE_ID_ENDPOINTS = new Set(['vcs/revision-compare', 'vcs/revision-image']);
73
+ const SELECTED_CHANGE_ENDPOINTS = new Set(['vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/commit-image']);
74
+ const COMMIT_ENDPOINTS = new Set(['vcs/commit', 'vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-page', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image']);
75
+ const LARGE_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/commit-compare', 'vcs/revision-compare']);
76
+ const HISTORY_PATH_ENDPOINTS = new Set(['vcs/file-history', 'vcs/tree-history']);
77
+ const PAGINATED_HISTORY_ENDPOINTS = new Set(['vcs/history', 'vcs/file-history', 'vcs/tree-history']);
78
+ const IMAGE_ENDPOINTS = new Set(['vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image']);
79
+ const WORKSPACE_CHANGE_ENDPOINTS = new Set(['vcs/workspace-image', 'vcs/conflict-stages']);
80
+ const CHANGE_ID_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/workspace-image']);
81
+ const IMAGE_SIDES = new Set(['left', 'right']);
82
+ const COMPARISON_MODES = new Set(['all', 'unstaged', 'staged']);
83
+
84
+ /** Repositories requests may only narrow discovery to a relative Session subdirectory. */
85
+ function validateRepositoriesPayload(payload) {
86
+ if (payload.subdirectory === undefined) return;
87
+ const dir = payload.subdirectory;
88
+ if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
89
+ }
90
+
91
+ /** Historical paths are repository-relative and can never be absolute or escape. */
92
+ function validateHistoricalPath(endpoint, path) {
93
+ if (endpoint === 'vcs/tree-page' && (path === undefined || path === '')) return;
94
+ if (endpoint !== 'vcs/tree-page' && !HISTORICAL_PATH_ENDPOINTS.has(endpoint)) return;
95
+ if (typeof path !== 'string' || !path || path.length > 32768 || path.includes('\0') || path.startsWith('/') || path.split('/').some(part => !part || part === '.' || part === '..')) throw invalid('Invalid historical file path.');
96
+ }
97
+
98
+ function validateImageSide(endpoint, side) {
99
+ if (IMAGE_ENDPOINTS.has(endpoint) && side !== undefined && !IMAGE_SIDES.has(side)) throw invalid('Invalid image side.');
100
+ }
101
+
102
+ function validateRevisionSelection(endpoint, payload) {
103
+ if (!REVISION_PAIR_ENDPOINTS.has(endpoint)) return;
104
+ for (const oid of [payload.base, payload.target]) if (typeof oid !== 'string' || !isObjectId(oid)) throw invalid('Invalid revision commit id.');
105
+ if (REVISION_CHANGE_ID_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid revision change id.');
106
+ }
107
+
108
+ function validateSelectedChangeId(endpoint, payload) {
109
+ if (SELECTED_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid historical change id.');
110
+ }
111
+
112
+ function validateCommitSelection(endpoint, payload) {
113
+ if (!COMMIT_ENDPOINTS.has(endpoint)) return;
114
+ if (typeof payload.commit !== 'string' || !isObjectId(payload.commit)) throw invalid('Invalid commit id.');
115
+ if (payload.parentIndex !== undefined && (!Number.isInteger(payload.parentIndex) || payload.parentIndex < 0 || payload.parentIndex > 100)) throw invalid('Invalid parent index.');
116
+ }
117
+
118
+ function validateLargeComparison(endpoint, payload) {
119
+ if (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large !== undefined && typeof payload.large !== 'boolean') throw invalid('Invalid large comparison mode.');
120
+ }
121
+
122
+ function validateBlameWindow(endpoint, payload) {
123
+ if (endpoint !== 'vcs/blame') return;
124
+ const startInvalid = payload.startLine !== undefined && (!Number.isInteger(payload.startLine) || payload.startLine < 1 || payload.startLine > 100001);
125
+ const limitInvalid = payload.lineLimit !== undefined && (!Number.isInteger(payload.lineLimit) || payload.lineLimit < 1 || payload.lineLimit > 500);
126
+ if (startInvalid || limitInvalid) throw invalid('Invalid blame line window.');
127
+ }
128
+
129
+ function validateContentSearch(endpoint, payload) {
130
+ if (endpoint !== 'vcs/grep') return;
131
+ if (typeof payload.query !== 'string' || !payload.query || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query)) throw invalid('Invalid content search query.');
132
+ if (payload.commit !== undefined && (typeof payload.commit !== 'string' || !isObjectId(payload.commit))) throw invalid('Invalid content search commit.');
133
+ }
134
+
135
+ function validateRenameFollow(endpoint, payload) {
136
+ if (HISTORY_PATH_ENDPOINTS.has(endpoint) && payload.follow !== undefined && typeof payload.follow !== 'boolean') throw invalid('Invalid rename follow mode.');
137
+ }
138
+
139
+ function validateHistoryDates(endpoint, payload) {
140
+ if (!HISTORY_PATH_ENDPOINTS.has(endpoint) || (payload.since === undefined && payload.until === undefined)) return;
141
+ try {
142
+ if (payload.since !== undefined) validateDateBound(payload.since);
143
+ if (payload.until !== undefined) validateDateBound(payload.until);
144
+ } catch { throw invalid('Invalid history date bound.'); }
145
+ }
146
+
147
+ function validateHistoryPagination(endpoint, payload) {
148
+ if (!PAGINATED_HISTORY_ENDPOINTS.has(endpoint)) return;
149
+ if (payload.offset === null || payload.limit === null) throw invalid('Invalid history pagination.');
150
+ if (payload.snapshot !== undefined && (typeof payload.snapshot !== 'string' || !isObjectId(payload.snapshot))) throw invalid('Invalid history snapshot.');
151
+ const offset = payload.offset ?? 0, limit = payload.limit ?? 50;
152
+ if (!Number.isInteger(offset) || offset < 0 || offset > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid history pagination.');
153
+ }
154
+
155
+ function validateWorkspaceChangeId(endpoint, payload) {
156
+ if (WORKSPACE_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid workspace image change id.');
157
+ }
158
+
159
+ function validateModeAndComparisonId(endpoint, payload) {
160
+ if (!COMPARISON_MODES.has(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
161
+ if (CHANGE_ID_COMPARISON_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
162
+ }
163
+
164
+ /** The validation order below is part of the protocol: the first failing group wins. */
165
+ function validateRepositoryPayload(endpoint, payload) {
166
+ if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
167
+ validateHistoricalPath(endpoint, payload.path);
168
+ validateImageSide(endpoint, payload.side);
169
+ validateRevisionSelection(endpoint, payload);
170
+ validateSelectedChangeId(endpoint, payload);
171
+ validateCommitSelection(endpoint, payload);
172
+ validateLargeComparison(endpoint, payload);
173
+ if (endpoint === 'vcs/tree-segment' && payload.offset !== undefined && (!Number.isInteger(payload.offset) || payload.offset < 0 || payload.offset > 16777216)) throw invalid('Invalid segment offset.');
174
+ validateBlameWindow(endpoint, payload);
175
+ validateContentSearch(endpoint, payload);
176
+ validateRenameFollow(endpoint, payload);
177
+ validateHistoryDates(endpoint, payload);
178
+ if (endpoint === 'vcs/history') { try { historySearchArgs(payload.search); } catch { throw invalid('Invalid history search.'); } }
179
+ validateHistoryPagination(endpoint, payload);
180
+ if (endpoint === 'vcs/tree-page') {
181
+ const {offset = 0, limit = 100} = payload;
182
+ if (payload.query !== undefined && (typeof payload.query !== 'string' || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query))) throw invalid('Invalid tree query.');
183
+ if (!Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid tree pagination.');
184
+ }
185
+ validateWorkspaceChangeId(endpoint, payload);
186
+ validateModeAndComparisonId(endpoint, payload);
187
+ }
188
+
57
189
  function validatePayload(endpoint, payload) {
58
190
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
59
- const fields = REQUEST_FIELDS[endpoint];
60
- if (Object.keys(payload).some(key => !fields.includes(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
61
- if (endpoint === 'vcs/repositories') {
62
- if (payload.subdirectory !== undefined) {
63
- const dir = payload.subdirectory;
64
- if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
65
- }
66
- } else {
67
- if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
68
- if(['vcs/tree-file','vcs/tree-segment','vcs/tree-history'].includes(endpoint)&&(typeof payload.path!=='string'||!payload.path||payload.path.length>32768||payload.path.includes('\0')||payload.path.startsWith('/')||payload.path.split('/').some(part=>!part||part==='.'||part==='..')))throw invalid('Invalid historical file path.');
69
- if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
70
- if(['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint)){
71
- for(const oid of [payload.base,payload.target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw invalid('Invalid revision commit id.');
72
- if(['vcs/revision-compare','vcs/revision-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
73
- }
74
- if(['vcs/commit-compare','vcs/file-history','vcs/blame','vcs/commit-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid historical change id.');
75
- if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/commit-image'].includes(endpoint)){
76
- if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
77
- if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
78
- }
79
- if(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
80
- if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
81
- if(endpoint==='vcs/blame'&&((payload.startLine!==undefined&&(!Number.isInteger(payload.startLine)||payload.startLine<1||payload.startLine>100001))||(payload.lineLimit!==undefined&&(!Number.isInteger(payload.lineLimit)||payload.lineLimit<1||payload.lineLimit>500))))throw invalid('Invalid blame line window.');
82
- if(endpoint==='vcs/grep'){if(typeof payload.query!=='string'||!payload.query||payload.query.length>1024||/[\0\r\n]/.test(payload.query))throw invalid('Invalid content search query.');if(payload.commit!==undefined&&(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit)))throw invalid('Invalid content search commit.');}
83
- if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
84
- if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&(payload.since!==undefined||payload.until!==undefined)){try{if(payload.since!==undefined)validateDateBound(payload.since);if(payload.until!==undefined)validateDateBound(payload.until);}catch{throw invalid('Invalid history date bound.');}}
85
- if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
86
- if(['vcs/history','vcs/file-history','vcs/tree-history'].includes(endpoint)){
87
- if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
88
- if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
89
- if(!Number.isInteger(payload.offset??0)||(payload.offset??0)<0||(payload.offset??0)>10000||!Number.isInteger(payload.limit??50)||(payload.limit??50)<1||(payload.limit??50)>100)throw invalid('Invalid history pagination.');
90
- }
91
- if(['vcs/workspace-image','vcs/conflict-stages'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid workspace image change id.');
92
- if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
93
- if (['vcs/compare','vcs/workspace-image'].includes(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
94
- }
191
+ const allowedFields = REQUEST_FIELD_SETS.get(endpoint);
192
+ if (Object.keys(payload).some(key => !allowedFields.has(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
193
+ if (endpoint === 'vcs/repositories') validateRepositoriesPayload(payload);
194
+ else validateRepositoryPayload(endpoint, payload);
95
195
  }
96
196
 
197
+ /** Forward an optional field only when the request actually supplied it. */
198
+ const optionalField = (key, value) => value === undefined ? {} : {[key]: value};
199
+
97
200
  /** Dispatch only after grants and capacity have been checked by createHandler. */
98
201
  async function readRepository(api, endpoint, repository, payload, {cwd, mode, signal}) {
99
202
  switch (endpoint) {
203
+ case 'vcs/tree-page':
204
+ return api.getHistoricalTreePage({...repository}, {commit: payload.commit, path: payload.path ?? '', offset: payload.offset ?? 0, limit: payload.limit ?? 100, ...optionalField('query', payload.query), signal});
100
205
  case 'vcs/tree-history':
101
- return api.listPathHistory({...repository},{commit:payload.commit,path:payload.path,offset:payload.offset??0,limit:payload.limit??50,follow:payload.follow??false, ...(payload.since !== undefined ? {since: payload.since} : {}), ...(payload.until !== undefined ? {until: payload.until} : {}), signal});
206
+ return api.listPathHistory({...repository}, {
207
+ commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, limit: payload.limit ?? 50, follow: payload.follow ?? false,
208
+ ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
209
+ });
102
210
  case 'vcs/tree-segment':
103
211
  return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, signal});
104
212
  case 'vcs/workspace-image':
@@ -118,19 +226,21 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
118
226
  case 'vcs/tree':
119
227
  return api.getHistoricalTree({...repository}, {commit: payload.commit, signal});
120
228
  case 'vcs/blame':
121
- return api.getFileBlame({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
122
- ...(payload.startLine !== undefined ? {startLine: payload.startLine} : {}),
123
- ...(payload.lineLimit !== undefined ? {lineLimit: payload.lineLimit} : {}), signal});
229
+ return api.getFileBlame({...repository}, {
230
+ commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
231
+ ...optionalField('startLine', payload.startLine), ...optionalField('lineLimit', payload.lineLimit), signal,
232
+ });
124
233
  case 'vcs/file-history':
125
- return api.listFileHistory({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
126
- offset: payload.offset ?? 0, limit: payload.limit ?? 50,
127
- ...(payload.follow !== undefined ? {follow: payload.follow} : {}), ...(payload.since !== undefined ? {since: payload.since} : {}), ...(payload.until !== undefined ? {until: payload.until} : {}), signal});
234
+ return api.listFileHistory({...repository}, {
235
+ commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, offset: payload.offset ?? 0, limit: payload.limit ?? 50,
236
+ ...optionalField('follow', payload.follow), ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
237
+ });
128
238
  case 'vcs/revision-changes':
129
239
  return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
130
240
  case 'vcs/revision-compare':
131
- return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
241
+ return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...optionalField('large', payload.large), signal});
132
242
  case 'vcs/commit-compare':
133
- return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
243
+ return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...optionalField('large', payload.large), signal});
134
244
  case 'vcs/commit':
135
245
  return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
136
246
  case 'vcs/history':
@@ -138,106 +248,194 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
138
248
  case 'vcs/status':
139
249
  return {cwd, repository: {...repository}, changes: await api.listChanges({...repository}, mode, {signal}), mode};
140
250
  case 'vcs/conflict-stages':
141
- return api.getConflictStages({...repository},{mode,id:payload.id,signal});
251
+ return api.getConflictStages({...repository}, {mode, id: payload.id, signal});
142
252
  case 'vcs/compare':
143
- return api.getComparison({...repository}, {mode, id: payload.id, ...(signal !== undefined ? {signal} : {}), ...(payload.large !== undefined ? {large: payload.large} : {})});
253
+ return api.getComparison({...repository}, {mode, id: payload.id, ...optionalField('signal', signal), ...optionalField('large', payload.large)});
144
254
  }
145
255
  }
146
256
 
147
257
  /** Limit active adapter operations and retain only bounded, short-lived discovery grants. */
148
- export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, bindSvnIdentityResolver } = {}) {
258
+ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, operationTimeoutMs = 15000, bindSvnIdentityResolver } = {}) {
149
259
  let active = 0, activeImages = 0, activeSegments = 0;
150
260
  const sessions = new Map();
261
+
262
+ const clearGrantCache = entry => {
263
+ for (const repository of entry?.repositories.values() ?? []) api.clearHistoricalCache?.(repository.root);
264
+ };
265
+ const revokeSession = id => { clearGrantCache(sessions.get(id)); sessions.delete(id); };
266
+
267
+ /** Resolve a live Session grant, evicting expired entries and cwd-mismatched grants. */
151
268
  function current(sessionId, cwd) {
152
- for (const [id, entry] of sessions) if (entry.expiresAt <= now()) sessions.delete(id);
269
+ for (const [id, entry] of sessions) if (entry.expiresAt <= now()) revokeSession(id);
153
270
  const entry = sessions.get(sessionId);
154
- if (entry && entry.cwd !== cwd) { sessions.delete(sessionId); return undefined; }
271
+ if (entry && entry.cwd !== cwd) { revokeSession(sessionId); return undefined; }
155
272
  return entry;
156
273
  }
274
+
275
+ /** Re-read the Session cwd, then require the same grant object to still be current. */
157
276
  async function assertCurrent(sessionId, cwd, entry, signal) {
158
277
  const latest = await resolveSessionCwd(ctx, sessionId, signal);
159
278
  signal?.throwIfAborted();
160
279
  if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
161
280
  }
162
- if(bindSvnIdentityResolver!==undefined){if(typeof bindSvnIdentityResolver!=='function')throw new Error('Invalid internal SVN resolver binding');bindSvnIdentityResolver(async(address,signal)=>{validatePayload('vcs/svn-identity',address);signal?.throwIfAborted();const cwd=await resolveSessionCwd(ctx,address.sessionId,signal),entry=current(address.sessionId,cwd),repository=entry?.repositories.get(address.repositoryId);if(!repository)throw rediscover();if(repository.type!=='svn')throw invalid('SVN working copy required.');const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(address.sessionId,cwd,entry,signal);if(entry.repositories.get(address.repositoryId)!==repository)throw rediscover();return {...identity,cwd};});}
163
- return async (endpoint, payload, signal) => {
164
- if (['vcs/runtime-info','vcs/check-update'].includes(endpoint)) {
165
- try {
166
- signal?.throwIfAborted();
167
- if (!payload || typeof payload!=='object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
168
- if (endpoint==='vcs/runtime-info') return {ok:true,value:runtimeInfo()};
169
- if (active>=maxActive) return failure('vcs/busy','Too many VCS requests. Please retry.');
170
- active++;try { return {ok:true,value:await checkUpdate({signal})}; } finally { active--; }
171
- } catch(error) { return failure(signal?.aborted?'vcs/cancelled':'vcs/update-failed',error.message); }
281
+
282
+ /** Adapter work finished: both the cwd grant and the selected repository must still be current. */
283
+ async function assertCurrentGrant(sessionId, cwd, entry, repositoryId, repository, signal) {
284
+ await assertCurrent(sessionId, cwd, entry, signal);
285
+ if (entry.repositories.get(repositoryId) !== repository) throw rediscover();
286
+ }
287
+
288
+ if (bindSvnIdentityResolver !== undefined) {
289
+ if (typeof bindSvnIdentityResolver !== 'function') throw new Error('Invalid internal SVN resolver binding');
290
+ bindSvnIdentityResolver(async (address, signal) => {
291
+ validatePayload('vcs/svn-identity', address);
292
+ signal?.throwIfAborted();
293
+ const cwd = await resolveSessionCwd(ctx, address.sessionId, signal);
294
+ const entry = current(address.sessionId, cwd);
295
+ const repository = entry?.repositories.get(address.repositoryId);
296
+ if (!repository) throw rediscover();
297
+ if (repository.type !== 'svn') throw invalid('SVN working copy required.');
298
+ const identity = await api.getSvnIdentity({...repository}, {signal});
299
+ await assertCurrentGrant(address.sessionId, cwd, entry, address.repositoryId, repository, signal);
300
+ return {...identity, cwd};
301
+ });
302
+ }
303
+
304
+ /** Update queries accept no fields and are admitted separately from repository reads. */
305
+ async function handleUpdateRequest(endpoint, payload, signal) {
306
+ try {
307
+ signal?.throwIfAborted();
308
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
309
+ if (endpoint === 'vcs/runtime-info') return {ok: true, value: runtimeInfo()};
310
+ if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
311
+ active++;
312
+ try { return {ok: true, value: await checkUpdate({signal})}; } finally { active--; }
313
+ } catch (error) {
314
+ return failure(signal?.aborted ? 'vcs/cancelled' : 'vcs/update-failed', error.message);
315
+ }
316
+ }
317
+
318
+ /** Discovery creates, replaces or merges grants; a stale scan can never resurrect one. */
319
+ async function discoverRepositories(payload, cwd, entry, signal) {
320
+ if (!entry) {
321
+ while (sessions.size >= maxSessions) revokeSession(sessions.keys().next().value);
322
+ entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
323
+ sessions.set(payload.sessionId, entry);
324
+ }
325
+ const targeted = payload.subdirectory !== undefined;
326
+ const sequence = ++entry.scanSequence;
327
+ if (!targeted) entry.fullScanSequence = sequence;
328
+ const fullSequence = entry.fullScanSequence;
329
+ const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
330
+ // A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
331
+ await assertCurrent(payload.sessionId, cwd, entry, signal);
332
+ // Full scans replace grants. Never let an older full scan erase a newer scan,
333
+ // or an older targeted scan resurrect grants after a new full scan begins.
334
+ if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
335
+ const grants = targeted ? new Map(entry.repositories) : new Map();
336
+ let truncated = !!result.truncated;
337
+ const warnings = [...result.warnings];
338
+ const repositories = [];
339
+ for (const repository of result.repositories) {
340
+ if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
341
+ grants.set(repository.id, { ...repository });
342
+ repositories.push({ ...repository });
343
+ }
344
+ if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
345
+ clearGrantCache(entry);
346
+ entry.repositories = grants;
347
+ entry.expiresAt = now() + ttlMs;
348
+ return { cwd, repositories, truncated, warnings };
349
+ }
350
+
351
+ /** Read and revalidate SVN identity, returning only the fixed remote projection. */
352
+ async function readSvnIdentity(payload, cwd, entry, repository, signal) {
353
+ if (repository.type !== 'svn') throw invalid('SVN working copy required.');
354
+ const identity = await api.getSvnIdentity({...repository}, {signal});
355
+ await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
356
+ return {
357
+ root: identity.root, uuid: identity.uuid, scope: identity.scope,
358
+ origin: identity.origin, revision: identity.revision, remoteEnabled: false,
359
+ };
360
+ }
361
+
362
+ /** Reserve the independent image or text-segment slot; report a busy failure instead of queuing. */
363
+ function admitHeavyOperation(endpoint, payload) {
364
+ let imageSlot = false, segmentSlot = false;
365
+ if (endpoint === 'vcs/tree-segment' || (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large === true)) {
366
+ if (activeSegments >= 1) return {busy: failure('vcs/busy', 'A text segment is already loading. Please retry.'), imageSlot, segmentSlot};
367
+ activeSegments++; segmentSlot = true;
368
+ }
369
+ if (IMAGE_ENDPOINTS.has(endpoint)) {
370
+ if (activeImages >= 2) return {busy: failure('vcs/busy', 'Too many image requests. Please retry.'), imageSlot, segmentSlot};
371
+ activeImages++; imageSlot = true;
172
372
  }
173
- if (!['vcs/svn-identity', 'vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/conflict-stages', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image', 'vcs/references', 'vcs/stash-list', 'vcs/grep'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
373
+ return {busy: null, imageSlot, segmentSlot};
374
+ }
375
+
376
+ /** Read through the adapter, then require both the cwd grant and the selected repository to be current. */
377
+ async function readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) {
378
+ const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
379
+ await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
380
+ return value;
381
+ }
382
+
383
+ const execute = async (endpoint, payload, signal) => {
384
+ if (UPDATE_ENDPOINTS.has(endpoint)) return handleUpdateRequest(endpoint, payload, signal);
385
+ if (!ACTION_ENDPOINTS.has(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
174
386
  if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
175
387
  active++;
176
- let imageSlot=false,segmentSlot=false;
388
+ let imageSlot = false, segmentSlot = false;
177
389
  try {
178
390
  signal?.throwIfAborted();
179
391
  validatePayload(endpoint, payload);
180
392
  const cwd = await resolveSessionCwd(ctx, payload.sessionId, signal);
181
393
  signal?.throwIfAborted();
182
- let entry = current(payload.sessionId, cwd);
183
- if (endpoint === 'vcs/repositories') {
184
- if (!entry) {
185
- while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
186
- entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
187
- sessions.set(payload.sessionId, entry);
188
- }
189
- const targeted = payload.subdirectory !== undefined;
190
- const sequence = ++entry.scanSequence;
191
- if (!targeted) entry.fullScanSequence = sequence;
192
- const fullSequence = entry.fullScanSequence;
193
- const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
194
- // A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
195
- await assertCurrent(payload.sessionId, cwd, entry, signal);
196
- // Full scans replace grants. Never let an older full scan erase a newer scan,
197
- // or an older targeted scan resurrect grants after a new full scan begins.
198
- if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
199
- const grants = targeted ? new Map(entry.repositories) : new Map();
200
- let truncated = !!result.truncated;
201
- const warnings = [...result.warnings];
202
- const repositories = [];
203
- for (const repository of result.repositories) {
204
- if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
205
- grants.set(repository.id, { ...repository });
206
- repositories.push({ ...repository });
207
- }
208
- if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
209
- entry.repositories = grants;
210
- entry.expiresAt = now() + ttlMs;
211
- return { ok: true, value: { cwd, repositories, truncated, warnings } };
212
- }
394
+ const entry = current(payload.sessionId, cwd);
395
+ if (endpoint === 'vcs/repositories') return { ok: true, value: await discoverRepositories(payload, cwd, entry, signal) };
213
396
  const repository = entry?.repositories.get(payload.repositoryId);
214
397
  if (!repository) throw rediscover();
215
- if(endpoint==='vcs/svn-identity'){
216
- if(repository.type!=='svn')throw invalid('SVN working copy required.');
217
- const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(payload.sessionId,cwd,entry,signal);if(entry.repositories.get(payload.repositoryId)!==repository)throw rediscover();
218
- return {ok:true,value:{root:identity.root,uuid:identity.uuid,scope:identity.scope,origin:identity.origin,revision:identity.revision,remoteEnabled:false}};
219
- }
398
+ if (endpoint === 'vcs/svn-identity') return { ok: true, value: await readSvnIdentity(payload, cwd, entry, repository, signal) };
220
399
  const mode = payload.mode ?? 'all';
221
400
  if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
222
401
  // Both adapter operations revalidate the canonical root; comparison also validates
223
402
  // the change ID against fresh status, so do not duplicate a full status scan here.
224
- if(['vcs/history','vcs/commit','vcs/commit-compare','vcs/revision-changes','vcs/revision-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/conflict-stages','vcs/commit-image','vcs/revision-image','vcs/workspace-image','vcs/references','vcs/stash-list','vcs/grep'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
225
- if(endpoint==='vcs/tree-segment'||(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large===true)){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
226
- if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)){
227
- if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
228
- activeImages++;imageSlot=true;
229
- }
230
- const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
231
- await assertCurrent(payload.sessionId, cwd, entry, signal);
232
- if (entry.repositories.get(payload.repositoryId) !== repository) throw rediscover();
233
- return { ok: true, value };
403
+ if (GIT_ONLY_ENDPOINTS.has(endpoint) && repository.type !== 'git') throw invalid('History currently supports Git only.');
404
+ const admission = admitHeavyOperation(endpoint, payload);
405
+ imageSlot = admission.imageSlot;
406
+ segmentSlot = admission.segmentSlot;
407
+ if (admission.busy) return admission.busy;
408
+ return { ok: true, value: await readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) };
409
+ } catch (error) {
410
+ if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
411
+ const code = error?.code === 'TIMEOUT' ? 'vcs/timeout' : typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed';
412
+ return failure(code, error instanceof Error ? error.message : String(error));
413
+ } finally {
414
+ active--;
415
+ if (imageSlot) activeImages--;
416
+ if (segmentSlot) activeSegments--;
417
+ }
418
+ };
419
+ return async (endpoint, payload, signal) => {
420
+ try {
421
+ return await withOperationBudget({signal, timeoutMs: operationTimeoutMs}, () => execute(endpoint, payload, signal));
234
422
  } catch (error) {
235
423
  if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
236
- return failure(typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed', error instanceof Error ? error.message : String(error));
237
- } finally { active--; if(imageSlot)activeImages--;if(segmentSlot)activeSegments--; }
424
+ return failure(error?.code === 'TIMEOUT' ? 'vcs/timeout' : 'vcs/operation-failed', error.message);
425
+ }
238
426
  };
239
427
  }
240
428
 
429
+ /** Content type is fixed per build artifact; URL input never chooses it. */
430
+ const assetContentType = filename => filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8';
431
+ /** Shared bounded-response headers for both asset transports. */
432
+ const assetHeaders = (filename, bytes) => ({
433
+ 'Content-Type': assetContentType(filename),
434
+ 'Content-Length': String(bytes.byteLength),
435
+ 'Cache-Control': 'private, no-cache',
436
+ 'X-Content-Type-Options': 'nosniff',
437
+ });
438
+
241
439
  /** Three fixed authenticated assets; URL input never enters a filesystem path. */
242
440
  export function registerAssets(ctx, load = filename => readFile(new URL('./dist/' + filename, import.meta.url))) {
243
441
  for (const filename of ASSETS) {
@@ -248,12 +446,7 @@ export function registerAssets(ctx, load = filename => readFile(new URL('./dist/
248
446
  async fetch(request) {
249
447
  try {
250
448
  const bytes = await load(filename);
251
- return new Response(request.method === 'HEAD' ? null : bytes, { headers: {
252
- 'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
253
- 'Content-Length': String(bytes.byteLength),
254
- 'Cache-Control': 'private, no-cache',
255
- 'X-Content-Type-Options': 'nosniff',
256
- } });
449
+ return new Response(request.method === 'HEAD' ? null : bytes, { headers: assetHeaders(filename, bytes) });
257
450
  } catch { return new Response('VCS editor assets unavailable. Build the plugin first.', { status: 503 }); }
258
451
  },
259
452
  });
@@ -270,7 +463,7 @@ export function registerDirectAssets(ctx, load = filename => readFile(new URL('.
270
463
  if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, {Allow:'GET, HEAD'}); res.end(); return; }
271
464
  try {
272
465
  const bytes = await load(filename);
273
- res.writeHead(200, {'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', 'Content-Length': String(bytes.byteLength), 'Cache-Control':'private, no-cache', 'X-Content-Type-Options':'nosniff'});
466
+ res.writeHead(200, assetHeaders(filename, bytes));
274
467
  res.end(req.method === 'HEAD' ? undefined : bytes);
275
468
  } catch { res.writeHead(503, {'Content-Type':'text/plain; charset=utf-8'}); res.end('VCS editor assets unavailable.'); }
276
469
  }
@@ -281,7 +474,7 @@ export function apply(ctx) {
281
474
  // Pass the plugin owner explicitly: the nested rpc getter can retain the
282
475
  // connection service context, which does not inject webServer.
283
476
  const host=createSvnHttpsHost(ctx,adapter);
284
- ctx.effect(()=>()=>host.dispose());
477
+ ctx.effect(()=>()=>{ host.dispose(); adapter.clearHistoricalCache?.(); });
285
478
  ctx.connection.register(ctx, RPC_CHANNEL, host.handle);
286
479
  registerAssets(ctx);
287
480
  registerDirectAssets(ctx);