engineering-memory 1.7.0 → 1.9.0

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.
@@ -1,4 +1,47 @@
1
1
  import { sha256, stableStringify } from '../utilities/hash.js';
2
+ export const backendRecoveryOperationNames = [
3
+ 'auth.signin_browser',
4
+ 'auth.web_csrf',
5
+ 'auth.web_signin',
6
+ 'audit.list',
7
+ 'organization.member_list',
8
+ 'organization.member_upsert',
9
+ 'project.setup',
10
+ 'project.resolve',
11
+ 'project.list',
12
+ 'project.restore',
13
+ 'project.member_list',
14
+ 'organization.list',
15
+ 'project.member_add',
16
+ 'work_item.list',
17
+ 'work_item.get',
18
+ 'session.bootstrap',
19
+ 'session.resume',
20
+ 'context.refresh',
21
+ 'context.prepare_change',
22
+ 'memory.query',
23
+ 'memory.history',
24
+ 'memory.propose_revision',
25
+ 'memory.list_proposals',
26
+ 'memory.review_proposal',
27
+ 'architecture.plan',
28
+ 'architecture.record_application',
29
+ 'task.checkpoint',
30
+ 'task.reconcile',
31
+ 'task.self_review',
32
+ 'task.verify',
33
+ 'task.close',
34
+ 'task.abandon',
35
+ 'task.branch',
36
+ 'task.resolve_pending_delivery',
37
+ ];
38
+ const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
39
+ const browserSigninRecovery = 'auth.signin_browser';
40
+ export function normalizeBackendRecovery(value) {
41
+ return typeof value === 'string' && backendRecoveryOperations.has(value)
42
+ ? value
43
+ : null;
44
+ }
2
45
  export class BackendUnavailableError extends Error {
3
46
  causeValue;
4
47
  retryable = true;
@@ -14,14 +57,17 @@ export class ApiResponseError extends Error {
14
57
  retryable;
15
58
  envelope;
16
59
  recovery;
17
- constructor(message, httpStatus, code, retryable, envelope, recovery = null) {
18
- super(message);
60
+ constructor(message, httpStatus, code, retryable, envelope, recovery) {
61
+ const normalizedRecovery = normalizeBackendRecovery(recovery) ?? (httpStatus === 401 ? browserSigninRecovery : null);
62
+ super(normalizedRecovery && !message.includes('Recovery: call ')
63
+ ? `${message} Recovery: call ${normalizedRecovery}.`
64
+ : message);
19
65
  this.httpStatus = httpStatus;
20
66
  this.code = code;
21
67
  this.retryable = retryable;
22
68
  this.envelope = envelope;
23
- this.recovery = recovery;
24
69
  this.name = 'ApiResponseError';
70
+ this.recovery = normalizedRecovery;
25
71
  }
26
72
  }
27
73
  export class ApiClient {
@@ -53,7 +99,7 @@ export class ApiClient {
53
99
  if (request.authenticated !== false) {
54
100
  const accessToken = await this.options.credentials.get('access-token');
55
101
  if (!accessToken) {
56
- throw new ApiResponseError('Authentication is required', 401, null, false, null);
102
+ throw new ApiResponseError('Authentication is required. Recovery: call auth.signin_browser.', 401, null, false, null, browserSigninRecovery);
57
103
  }
58
104
  headers.Authorization = `Bearer ${accessToken}`;
59
105
  }
@@ -108,9 +154,9 @@ export class ApiClient {
108
154
  this.responseSources.set(cached.value, 'stale_cache');
109
155
  return cached.value;
110
156
  }
111
- const recovery = envelope.errorModel?.recovery ?? null;
157
+ const recovery = envelope.errorModel?.recovery ?? (response.status === 401 ? browserSigninRecovery : null);
112
158
  const refusal = envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`;
113
- throw new ApiResponseError(recovery ? `${refusal} Recovery: call ${recovery}.` : refusal, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope, recovery);
159
+ throw new ApiResponseError(refusal, response.status, envelope.errorModel?.code ?? null, retryableResponse(response.status, envelope.errorModel?.retryable), envelope, recovery);
114
160
  }
115
161
  if (request.cacheKey) {
116
162
  await this.options.cache.set(cacheKey, response.headers.get('etag'), envelope);
@@ -146,7 +192,7 @@ export class ApiClient {
146
192
  const refreshToken = await this.options.credentials.get('refresh-token');
147
193
  if (!refreshToken) {
148
194
  await this.options.credentials.clear();
149
- throw new ApiResponseError('Authentication has expired', 401, null, false, null);
195
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', 401, null, false, null, browserSigninRecovery);
150
196
  }
151
197
  const controller = new AbortController();
152
198
  const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
@@ -172,18 +218,19 @@ export class ApiClient {
172
218
  catch (error) {
173
219
  if (isDefinitiveRefreshRejection(response.status)) {
174
220
  await this.options.credentials.clear();
221
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', response.status, null, false, null, browserSigninRecovery);
175
222
  }
176
223
  throw error;
177
224
  }
178
225
  if (!response.ok || envelope.status === 'error') {
179
226
  if (isDefinitiveRefreshRejection(response.status)) {
180
227
  await this.options.credentials.clear();
181
- throw new ApiResponseError('Authentication has expired', response.status, null, false, envelope);
228
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', response.status, null, false, envelope, browserSigninRecovery);
182
229
  }
183
- throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? 'Token refresh failed', response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope);
230
+ throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? 'Token refresh failed', response.status, envelope.errorModel?.code ?? null, retryableResponse(response.status, envelope.errorModel?.retryable), envelope, null);
184
231
  }
185
232
  if (!isRefreshTokens(envelope.data)) {
186
- throw new ApiResponseError('Token refresh response is missing credentials', response.status, null, false, envelope);
233
+ throw new ApiResponseError('Token refresh response is missing credentials. Recovery: call auth.signin_browser.', response.status, null, false, envelope, browserSigninRecovery);
187
234
  }
188
235
  await this.options.credentials.set('access-token', envelope.data.accessToken);
189
236
  if (envelope.data.refreshToken) {
@@ -196,10 +243,10 @@ export class ApiClient {
196
243
  value = await response.json();
197
244
  }
198
245
  catch {
199
- throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null);
246
+ throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null, null);
200
247
  }
201
248
  if (!isEnvelope(value)) {
202
- throw new ApiResponseError('Backend response does not match the Engineering Memory envelope', response.status, null, false, null);
249
+ throw new ApiResponseError('Backend response does not match the Engineering Memory envelope', response.status, null, false, null, null);
203
250
  }
204
251
  return value;
205
252
  }
@@ -223,6 +270,9 @@ function isEnvelope(value) {
223
270
  'message' in candidate &&
224
271
  'errorModel' in candidate);
225
272
  }
273
+ function retryableResponse(status, declared) {
274
+ return declared ?? (status === 408 || status === 429 || status >= 500);
275
+ }
226
276
  function isRefreshTokens(value) {
227
277
  return (value !== null &&
228
278
  !Array.isArray(value) &&