engineering-memory 1.11.26 → 1.11.28

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.
@@ -0,0 +1,417 @@
1
+ import { isIP } from 'node:net';
2
+ import { join } from 'node:path';
3
+ import * as z from 'zod/v4';
4
+ import { principalFingerprint } from '../runtime/principal-state.js';
5
+ import { readJson, removeFile, writeJson } from '../utilities/files.js';
6
+ import { sha256 } from '../utilities/hash.js';
7
+ export function gitLabRemote(remote) {
8
+ const trimmed = remote.trim();
9
+ const scpLike = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(trimmed);
10
+ let scheme = 'ssh';
11
+ let hostname;
12
+ let port = '';
13
+ let path;
14
+ if (scpLike &&
15
+ !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) &&
16
+ !/^[A-Za-z]:[\\/]/.test(trimmed)) {
17
+ hostname = scpLike[1].toLowerCase();
18
+ path = scpLike[2];
19
+ }
20
+ else if (URL.canParse(trimmed)) {
21
+ const url = new URL(trimmed);
22
+ scheme = url.protocol.slice(0, -1).toLowerCase();
23
+ hostname = url.hostname.toLowerCase();
24
+ port = url.port;
25
+ path = url.pathname;
26
+ }
27
+ else
28
+ return { refusal: 'local' };
29
+ if (!hostname || scheme === 'file')
30
+ return { refusal: 'local' };
31
+ if (isIP(hostname.replace(/^\[|\]$/g, '')))
32
+ return { refusal: 'ip' };
33
+ if (hostname === 'github.com' || hostname.endsWith('.github.com'))
34
+ return { refusal: 'github' };
35
+ const segments = path
36
+ .replace(/^\/+|\/+$/g, '')
37
+ .replace(/\.git$/i, '')
38
+ .split('/');
39
+ if (segments.length < 2 || !segments.every(pathSegment))
40
+ return { refusal: 'unsupported' };
41
+ if (scheme === 'https' || scheme === 'http') {
42
+ const host = port ? `${hostname}:${port}` : hostname;
43
+ return {
44
+ host,
45
+ address: scheme === 'http' && !loopback(hostname) ? `https://${hostname}` : `${scheme}://${host}`,
46
+ path: segments.join('/'),
47
+ };
48
+ }
49
+ if (['ssh', 'git+ssh', 'ssh+git', 'git'].includes(scheme))
50
+ return { host: hostname, address: `https://${hostname}`, path: segments.join('/') };
51
+ return { refusal: 'unsupported' };
52
+ }
53
+ export function gitLabAddress(value) {
54
+ const trimmed = value.trim();
55
+ if (trimmed.length > 300 || !URL.canParse(trimmed))
56
+ return null;
57
+ const url = new URL(trimmed);
58
+ const hostname = url.hostname.toLowerCase();
59
+ const path = url.pathname.replace(/\/+$/, '');
60
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback(hostname))) ||
61
+ url.username ||
62
+ url.password ||
63
+ url.search ||
64
+ url.hash ||
65
+ isIP(hostname.replace(/^\[|\]$/g, '')) ||
66
+ hostname === 'github.com' ||
67
+ hostname.endsWith('.github.com') ||
68
+ !path.split('/').slice(1).every(pathSegment))
69
+ return null;
70
+ return `${url.protocol}//${url.host.toLowerCase()}${path}`;
71
+ }
72
+ export function projectPath(address, path) {
73
+ const prefix = new URL(address).pathname.replace(/^\/+|\/+$/g, '');
74
+ return prefix && path.startsWith(prefix + '/') ? path.slice(prefix.length + 1) : path;
75
+ }
76
+ function pathSegment(segment) {
77
+ return /^[\w.-]+$/.test(segment) && segment !== '.' && segment !== '..';
78
+ }
79
+ function loopback(hostname) {
80
+ return hostname === 'localhost' || hostname.endsWith('.localhost');
81
+ }
82
+ export class GitLabError extends Error {
83
+ failure;
84
+ address;
85
+ status;
86
+ detail;
87
+ constructor(failure, address, status, detail) {
88
+ super(`GitLab at ${address}: ${failure}${status ? ` (${status})` : ''}${detail ? `: ${detail}` : ''}`);
89
+ this.failure = failure;
90
+ this.address = address;
91
+ this.status = status;
92
+ this.detail = detail;
93
+ this.name = 'GitLabError';
94
+ }
95
+ }
96
+ const certificateCodes = new Set([
97
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
98
+ 'UNABLE_TO_GET_ISSUER_CERT',
99
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
100
+ 'SELF_SIGNED_CERT_IN_CHAIN',
101
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
102
+ 'CERT_HAS_EXPIRED',
103
+ 'CERT_NOT_YET_VALID',
104
+ 'CERT_UNTRUSTED',
105
+ 'CERT_SIGNATURE_FAILURE',
106
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
107
+ ]);
108
+ const connectCodes = new Set([
109
+ 'ENOTFOUND',
110
+ 'EAI_AGAIN',
111
+ 'ECONNREFUSED',
112
+ 'EHOSTUNREACH',
113
+ 'ENETUNREACH',
114
+ 'EADDRNOTAVAIL',
115
+ 'UND_ERR_CONNECT_TIMEOUT',
116
+ ]);
117
+ function networkCode(error) {
118
+ let current = error;
119
+ for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) {
120
+ const code = current.code;
121
+ if (typeof code === 'string')
122
+ return code;
123
+ const tried = current.errors;
124
+ current = current.cause ?? (Array.isArray(tried) ? tried[0] : null);
125
+ }
126
+ return null;
127
+ }
128
+ function gitLabMessage(body) {
129
+ if (!body)
130
+ return null;
131
+ let message = body;
132
+ try {
133
+ const parsed = JSON.parse(body);
134
+ message = parsed.message ?? parsed.error ?? null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ const text = typeof message === 'string' ? message : message ? JSON.stringify(message) : '';
140
+ return text.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 200) || null;
141
+ }
142
+ export const mergeRequestSchema = z.object({
143
+ iid: z.number().int().positive(),
144
+ web_url: z.string().min(1).max(500),
145
+ state: z.string(),
146
+ draft: z.boolean().optional(),
147
+ work_in_progress: z.boolean().optional(),
148
+ sha: z.string().nullish(),
149
+ source_branch: z.string(),
150
+ target_branch: z.string(),
151
+ project_id: z.number().optional(),
152
+ source_project_id: z.number().optional(),
153
+ });
154
+ export function reviewState(request) {
155
+ if (request.state === 'merged')
156
+ return 'merged';
157
+ if (request.state === 'closed')
158
+ return 'closed';
159
+ return request.draft || request.work_in_progress ? 'draft' : 'ready';
160
+ }
161
+ export class GitLabClient {
162
+ timeoutMs;
163
+ constructor(timeoutMs) {
164
+ this.timeoutMs = timeoutMs;
165
+ }
166
+ async user(address, token) {
167
+ await this.call(address, token, 'GET', '/user');
168
+ }
169
+ async scopes(address, token) {
170
+ try {
171
+ const self = z
172
+ .object({ scopes: z.array(z.string()) })
173
+ .safeParse(await this.call(address, token, 'GET', '/personal_access_tokens/self'));
174
+ return self.success ? self.data.scopes : null;
175
+ }
176
+ catch (error) {
177
+ if (error instanceof GitLabError &&
178
+ ['not_found', 'not_allowed', 'invalid_request'].includes(error.failure))
179
+ return null;
180
+ throw error;
181
+ }
182
+ }
183
+ async project(address, token, project) {
184
+ return await this.found(async () => this.shape(address, z.object({ id: z.number() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}`)));
185
+ }
186
+ async branch(address, token, project, branch) {
187
+ const found = await this.found(async () => this.shape(address, z.object({ commit: z.object({ id: z.string(), title: z.string().nullish() }) }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/branches/${encodeURIComponent(branch)}`)));
188
+ return found ? { commit: found.commit.id, title: found.commit.title ?? '' } : null;
189
+ }
190
+ async contains(address, token, project, branch, commit) {
191
+ const query = new URLSearchParams([
192
+ ['refs[]', commit],
193
+ ['refs[]', branch],
194
+ ]);
195
+ try {
196
+ const base = this.shape(address, z.object({ id: z.string() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/merge_base?${query}`));
197
+ return base.id === commit;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof GitLabError &&
201
+ (error.failure === 'not_found' || error.failure === 'invalid_request'))
202
+ return false;
203
+ throw error;
204
+ }
205
+ }
206
+ async mergeRequests(address, token, project, branch, state) {
207
+ const query = new URLSearchParams({
208
+ source_branch: branch,
209
+ order_by: 'created_at',
210
+ sort: 'desc',
211
+ per_page: '20',
212
+ ...(state ? { state } : {}),
213
+ });
214
+ const listed = this.shape(address, z.array(mergeRequestSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project.path)}/merge_requests?${query}`));
215
+ return listed.filter((request) => request.source_branch === branch &&
216
+ (request.source_project_id === undefined || request.source_project_id === project.id));
217
+ }
218
+ async mergeRequest(address, token, project, number) {
219
+ return this.shape(address, mergeRequestSchema, await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`));
220
+ }
221
+ async createMergeRequest(address, token, project, request) {
222
+ return this.shape(address, mergeRequestSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests`, {
223
+ source_branch: request.source,
224
+ target_branch: request.target,
225
+ title: request.title,
226
+ ...(request.description ? { description: request.description } : {}),
227
+ }));
228
+ }
229
+ async found(read) {
230
+ try {
231
+ return await read();
232
+ }
233
+ catch (error) {
234
+ if (error instanceof GitLabError && error.failure === 'not_found')
235
+ return null;
236
+ throw error;
237
+ }
238
+ }
239
+ shape(address, schema, value) {
240
+ const parsed = schema.safeParse(value);
241
+ if (!parsed.success)
242
+ throw new GitLabError('unexpected_answer', address, null, null);
243
+ return parsed.data;
244
+ }
245
+ async call(address, token, method, path, body) {
246
+ let status;
247
+ let text;
248
+ try {
249
+ const response = await fetch(`${address}/api/v4${path}`, {
250
+ method,
251
+ redirect: 'manual',
252
+ headers: {
253
+ 'PRIVATE-TOKEN': token,
254
+ Accept: 'application/json',
255
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
256
+ },
257
+ ...(body ? { body: JSON.stringify(body) } : {}),
258
+ signal: AbortSignal.timeout(this.timeoutMs),
259
+ });
260
+ status = response.status;
261
+ text = await response.text();
262
+ }
263
+ catch (error) {
264
+ const code = networkCode(error);
265
+ if (code && certificateCodes.has(code))
266
+ throw new GitLabError('untrusted_certificate', address, null, code);
267
+ if ((code && connectCodes.has(code)) || method === 'GET')
268
+ throw new GitLabError('unreachable', address, null, code);
269
+ throw new GitLabError('unknown_outcome', address, null, code);
270
+ }
271
+ if (status >= 200 && status < 300) {
272
+ try {
273
+ return JSON.parse(text);
274
+ }
275
+ catch {
276
+ throw new GitLabError('unexpected_answer', address, status, null);
277
+ }
278
+ }
279
+ const failure = status >= 300 && status < 400
280
+ ? 'redirected'
281
+ : status === 401
282
+ ? 'token_rejected'
283
+ : status === 403
284
+ ? 'not_allowed'
285
+ : status === 404
286
+ ? 'not_found'
287
+ : status === 409
288
+ ? 'conflict'
289
+ : status === 429
290
+ ? 'rate_limited'
291
+ : status >= 500
292
+ ? 'server_error'
293
+ : 'invalid_request';
294
+ throw new GitLabError(failure, address, status, gitLabMessage(text));
295
+ }
296
+ }
297
+ const savedAddresses = z.object({
298
+ schemaVersion: z.literal(1),
299
+ principalHash: z.string().min(1),
300
+ addresses: z.array(z.object({ host: z.string().min(1), address: z.string().min(1) })),
301
+ });
302
+ export class GitLabTokens {
303
+ stateRoot;
304
+ credentials;
305
+ path;
306
+ queue = Promise.resolve();
307
+ constructor(stateRoot, credentials) {
308
+ this.stateRoot = stateRoot;
309
+ this.credentials = credentials;
310
+ this.path = join(stateRoot, 'gitlab-addresses.json');
311
+ }
312
+ async address(host) {
313
+ return (await this.current())?.addresses.find((entry) => entry.host === host)?.address ?? null;
314
+ }
315
+ async forLink(host, repository) {
316
+ const addresses = [...new Set((await this.current())?.addresses.map((entry) => entry.address))]
317
+ .filter((address) => {
318
+ const url = new URL(address);
319
+ const prefix = url.pathname.replace(/^\/+/, '');
320
+ return url.host === host && (!prefix || repository.startsWith(prefix + '/'));
321
+ })
322
+ .sort((left, right) => right.length - left.length);
323
+ return addresses[0]
324
+ ? { address: addresses[0], project: projectPath(addresses[0], repository) }
325
+ : null;
326
+ }
327
+ async token(address) {
328
+ const principal = await this.principal();
329
+ return principal ? await this.credentials.get(tokenName(principal, address)) : null;
330
+ }
331
+ async any() {
332
+ return Boolean((await this.current())?.addresses.length);
333
+ }
334
+ async save(host, address, token) {
335
+ await this.exclusive(async () => {
336
+ const principal = await this.principal();
337
+ if (!principal)
338
+ throw new Error('Sign in to Engineering Memory before saving a GitLab token.');
339
+ let saved = await this.read();
340
+ if (saved && saved.principalHash !== principal) {
341
+ await this.deleteTokens(saved);
342
+ saved = null;
343
+ }
344
+ const previous = saved?.addresses.find((entry) => entry.host === host)?.address;
345
+ await this.credentials.set(tokenName(principal, address), token);
346
+ const addresses = [
347
+ ...(saved?.addresses ?? []).filter((entry) => entry.host !== host),
348
+ { host, address },
349
+ ];
350
+ await writeJson(this.path, { schemaVersion: 1, principalHash: principal, addresses }, this.stateRoot);
351
+ if (previous && !addresses.some((entry) => entry.address === previous))
352
+ await this.credentials.delete(tokenName(principal, previous));
353
+ });
354
+ }
355
+ async remove(address) {
356
+ await this.exclusive(async () => {
357
+ const principal = await this.principal();
358
+ if (!principal)
359
+ return;
360
+ await this.credentials.delete(tokenName(principal, address));
361
+ const saved = await this.read();
362
+ if (saved?.principalHash !== principal)
363
+ return;
364
+ await writeJson(this.path, { ...saved, addresses: saved.addresses.filter((entry) => entry.address !== address) }, this.stateRoot);
365
+ });
366
+ }
367
+ async clear() {
368
+ await this.exclusive(async () => {
369
+ const saved = await this.read();
370
+ if (saved)
371
+ await this.deleteTokens(saved);
372
+ await removeFile(this.path, this.stateRoot);
373
+ });
374
+ }
375
+ async deleteTokens(saved) {
376
+ for (const address of new Set(saved.addresses.map((entry) => entry.address)))
377
+ await this.credentials.delete(tokenName(saved.principalHash, address));
378
+ }
379
+ async current() {
380
+ const principal = await this.principal();
381
+ const saved = principal ? await this.read() : null;
382
+ return saved?.principalHash === principal ? saved : null;
383
+ }
384
+ async read() {
385
+ const parsed = savedAddresses.safeParse(await readJson(this.path, this.stateRoot));
386
+ return parsed.success ? parsed.data : null;
387
+ }
388
+ async principal() {
389
+ const accessToken = await this.credentials.get('access-token');
390
+ if (!accessToken)
391
+ return null;
392
+ try {
393
+ return principalFingerprint(accessToken);
394
+ }
395
+ catch {
396
+ return null;
397
+ }
398
+ }
399
+ async exclusive(action) {
400
+ const previous = this.queue;
401
+ let release = () => undefined;
402
+ this.queue = new Promise((resolvePromise) => {
403
+ release = resolvePromise;
404
+ });
405
+ await previous;
406
+ try {
407
+ await action();
408
+ }
409
+ finally {
410
+ release();
411
+ }
412
+ }
413
+ }
414
+ function tokenName(principal, address) {
415
+ return `gitlab-token-${sha256(`${principal}\n${address}`).slice(0, 32)}`;
416
+ }
417
+ //# sourceMappingURL=gitlab.js.map
@@ -122,7 +122,7 @@ export class ActiveContextStore {
122
122
  }
123
123
  }
124
124
  }
125
- async setChangeBaseline(repoFingerprint, sessionId, manifest, leasePaths, taskSnapshot) {
125
+ async setChangeBaseline(repoFingerprint, sessionId, manifest, leasePaths, taskSnapshot, selfReviewRequired) {
126
126
  const pointer = taskSnapshot
127
127
  ? await this.loadForTask(repoFingerprint, taskSnapshot.taskId)
128
128
  : await this.loadForSession(repoFingerprint, sessionId);
@@ -140,13 +140,12 @@ export class ActiveContextStore {
140
140
  lastSequence: taskSnapshot.lastSequence,
141
141
  }
142
142
  : {}),
143
- changeBaseline: pointer.changeBaseline
144
- ? { ...pointer.changeBaseline, leasePaths }
145
- : {
146
- diffHash: manifest.diffHash,
147
- changedPaths: manifest.changedPaths,
148
- leasePaths,
149
- },
143
+ changeBaseline: {
144
+ diffHash: pointer.changeBaseline?.diffHash ?? manifest.diffHash,
145
+ changedPaths: pointer.changeBaseline?.changedPaths ?? manifest.changedPaths,
146
+ leasePaths,
147
+ ...(selfReviewRequired ? { selfReviewRequired } : {}),
148
+ },
150
149
  });
151
150
  }
152
151
  async setVerificationIntent(repoFingerprint, input) {
@@ -399,7 +398,11 @@ export class ActiveContextStore {
399
398
  pointer.changeBaseline.changedPaths.some((entry) => !isChangedPath(entry)) ||
400
399
  !Array.isArray(pointer.changeBaseline.leasePaths) ||
401
400
  pointer.changeBaseline.leasePaths.some((path) => !isRepositoryRelative(path)) ||
402
- new Set(pointer.changeBaseline.leasePaths).size !== pointer.changeBaseline.leasePaths.length) {
401
+ new Set(pointer.changeBaseline.leasePaths).size !==
402
+ pointer.changeBaseline.leasePaths.length ||
403
+ (pointer.changeBaseline.selfReviewRequired !== undefined &&
404
+ (!Array.isArray(pointer.changeBaseline.selfReviewRequired) ||
405
+ !pointer.changeBaseline.selfReviewRequired.every(isSelfReviewRecord)))) {
403
406
  throw new Error('Active Engineering Memory change baseline is invalid');
404
407
  }
405
408
  }
@@ -429,6 +432,13 @@ function isDurableIntent(value) {
429
432
  typeof value.createdAt === 'string' &&
430
433
  Number.isFinite(Date.parse(value.createdAt)));
431
434
  }
435
+ function isSelfReviewRecord(value) {
436
+ return (typeof value?.resourceId === 'string' &&
437
+ typeof value.resourceKey === 'string' &&
438
+ typeof value.title === 'string' &&
439
+ Array.isArray(value.paths) &&
440
+ value.paths.every((path) => typeof path === 'string'));
441
+ }
432
442
  function isChangedPath(value) {
433
443
  return (typeof value.path === 'string' &&
434
444
  isRepositoryRelative(value.path) &&
@@ -51,6 +51,7 @@ export const backendRecoveryOperationNames = [
51
51
  'task.branch',
52
52
  'task.resolve_pending_delivery',
53
53
  'task.delivery',
54
+ 'task.review_request',
54
55
  ];
55
56
  const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
56
57
  const browserSigninRecovery = 'auth.signin_browser';