@openfairygui/backend 0.2.0-alpha.0 → 0.2.0-alpha.10

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,25 +1,34 @@
1
- import { materializeUamProject, ProjectWriter } from '@openfairygui/core';
2
- import { applyUamTransactionApp, type ApplyUamTransactionAppError } from '@openfairygui/functions';
1
+ import { ProjectWriter, type FileSystem } from '@openfairygui/core/project-io';
2
+ import { materializeUamProject, validateUamProject } from '@openfairygui/core/uam';
3
+ import { applyUamTransactionApp, type ApplyUamTransactionAppError } from '@openfairygui/functions/uam';
3
4
  import type { CacheService } from './cache-service.js';
4
5
  import { failure, success, type BackendContext } from './context.js';
5
6
  import type { EventService } from './event-service.js';
6
7
  import type {
7
8
  ApplySessionTransactionInput,
9
+ BackendCapabilityUnavailableError,
8
10
  BackendFileSystem,
9
11
  BackendResult,
10
12
  BackendSessionSnapshot,
13
+ InProcessLockConflictError,
14
+ MaterializeSessionInput,
15
+ MaterializeSessionSnapshot,
16
+ MaterializeValidationFailedError,
17
+ MaterializeWriteFailedError,
18
+ SaveSessionInput,
11
19
  SavePartialFailureError,
12
20
  SessionNotFoundError,
13
21
  SessionStaleWriteError,
14
22
  } from '../runtime.js';
15
- import { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
23
+ import type { BackendDiagnostic } from '../contracts.js';
24
+ import { normalizeComparablePath, validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
16
25
  import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
17
26
 
18
27
  function createWriterFileSystem(
19
28
  fileSystem: BackendFileSystem,
20
29
  committedPaths: string[],
21
30
  failedPaths: string[],
22
- ): import('@openfairygui/core').FileSystem {
31
+ ): FileSystem {
23
32
  async function trackWrite<T>(targetPath: string, fn: () => Promise<T>): Promise<T> {
24
33
  try {
25
34
  const result = await fn();
@@ -73,6 +82,78 @@ function createWriterFileSystem(
73
82
  };
74
83
  }
75
84
 
85
+ function toBackendDiagnostics(error: ApplyUamTransactionAppError): BackendDiagnostic[] {
86
+ return error.diagnostics.length > 0
87
+ ? error.diagnostics.map((diagnostic) => ({ ...diagnostic }))
88
+ : [
89
+ {
90
+ code: error.code,
91
+ message: error.message,
92
+ severity: 'error',
93
+ operationKind: error.operationKind,
94
+ opIndex: error.opIndex,
95
+ opId: error.opId,
96
+ },
97
+ ];
98
+ }
99
+
100
+ function createCapabilityUnavailableError(message: string): BackendCapabilityUnavailableError {
101
+ return {
102
+ code: 'capability_unavailable',
103
+ message,
104
+ capability: 'fileSystem',
105
+ requiredAdapter: 'BackendFileSystem',
106
+ };
107
+ }
108
+
109
+ function validationDiagnostics(sessionProject: Parameters<typeof validateUamProject>[0]): BackendDiagnostic[] {
110
+ const issues = validateUamProject(sessionProject);
111
+ return issues.map((issue) => ({
112
+ code: 'materialize_validation_failed',
113
+ message: issue.message,
114
+ severity: 'error',
115
+ path: issue.path,
116
+ operationKind: 'materializeSession',
117
+ }));
118
+ }
119
+
120
+ function toMaterializeSnapshot(
121
+ session: Parameters<typeof toSessionSnapshot>[0],
122
+ capabilities: Parameters<typeof toSessionSnapshot>[1],
123
+ input: {
124
+ reason?: string;
125
+ writtenPaths: string[];
126
+ skippedPaths: string[];
127
+ diagnostics: BackendDiagnostic[];
128
+ },
129
+ ): MaterializeSessionSnapshot {
130
+ return {
131
+ ...toSessionSnapshot(session, capabilities),
132
+ mode: 'fullProject',
133
+ reason: input.reason,
134
+ materializeRevision: session.revision,
135
+ saveRevision: session.lastSavedRevision,
136
+ writtenPaths: [...input.writtenPaths],
137
+ skippedPaths: [...input.skippedPaths],
138
+ diagnostics: input.diagnostics.map((diagnostic) => ({ ...diagnostic })),
139
+ };
140
+ }
141
+
142
+ function storageCanonicalTarget(input: NonNullable<MaterializeSessionInput['storage']>): {
143
+ fileSystem: BackendFileSystem;
144
+ fairyPath: string;
145
+ canonicalProjectPath: string;
146
+ canonicalPathKey: string;
147
+ } {
148
+ const canonicalProjectPath = input.canonicalProjectPath ?? (input.fileSystem.dirname(input.fairyPath) || '.');
149
+ return {
150
+ fileSystem: input.fileSystem,
151
+ fairyPath: input.fairyPath,
152
+ canonicalProjectPath,
153
+ canonicalPathKey: input.canonicalPathKey ?? normalizeComparablePath(canonicalProjectPath),
154
+ };
155
+ }
156
+
76
157
  export class AuthoringService {
77
158
  public constructor(
78
159
  private readonly context: BackendContext,
@@ -82,18 +163,34 @@ export class AuthoringService {
82
163
 
83
164
  public async applyTransaction(
84
165
  input: ApplySessionTransactionInput,
85
- ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError>> {
166
+ ): Promise<
167
+ BackendResult<
168
+ BackendSessionSnapshot,
169
+ SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError
170
+ >
171
+ > {
86
172
  const startedAt = Date.now();
87
173
  const session = this.context.sessions.get(input.sessionId);
88
174
  if (!session || session.closed) {
89
175
  return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
90
176
  }
91
177
  if (input.expectedRevision !== session.revision) {
92
- this.eventService.emit({ kind: 'transaction.rejected', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
93
- return failure('authoring', startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
178
+ this.eventService.emit({
179
+ kind: 'transaction.rejected',
94
180
  sessionId: session.sessionId,
181
+ canonicalPathKey: session.canonicalPathKey,
95
182
  revision: session.revision,
96
183
  });
184
+ return failure(
185
+ 'authoring',
186
+ startedAt,
187
+ createStaleWriteError(session, input.expectedRevision),
188
+ toSessionSnapshot(session, this.context.capabilities),
189
+ {
190
+ sessionId: session.sessionId,
191
+ revision: session.revision,
192
+ },
193
+ );
97
194
  }
98
195
 
99
196
  const result = applyUamTransactionApp({
@@ -101,19 +198,44 @@ export class AuthoringService {
101
198
  operations: input.operations,
102
199
  });
103
200
  if (result.ok === false) {
104
- this.eventService.emit({ kind: 'transaction.rejected', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, diagnostics: result.error.issues?.map((issue) => ({ code: result.error.code, message: issue.message, severity: 'error' as const })) ?? [] });
105
- return failure('authoring', startedAt, result.error, toSessionSnapshot(session, this.context.capabilities), {
201
+ const diagnostics = toBackendDiagnostics(result.error);
202
+ this.eventService.emit({
203
+ kind: 'transaction.rejected',
106
204
  sessionId: session.sessionId,
205
+ canonicalPathKey: session.canonicalPathKey,
107
206
  revision: session.revision,
207
+ diagnostics,
108
208
  });
209
+ return failure(
210
+ 'authoring',
211
+ startedAt,
212
+ result.error,
213
+ toSessionSnapshot(session, this.context.capabilities),
214
+ {
215
+ sessionId: session.sessionId,
216
+ revision: session.revision,
217
+ diagnostics,
218
+ },
219
+ );
109
220
  }
110
221
 
111
222
  session.project = result.project;
112
223
  session.revision += 1;
113
224
  session.dirty = true;
114
225
  const cacheEntry = this.cacheService.invalidateSession(session);
115
- this.eventService.emit({ kind: 'transaction.applied', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
116
- this.eventService.emit({ kind: 'cache.invalidated', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, cacheRevision: cacheEntry.revision });
226
+ this.eventService.emit({
227
+ kind: 'transaction.applied',
228
+ sessionId: session.sessionId,
229
+ canonicalPathKey: session.canonicalPathKey,
230
+ revision: session.revision,
231
+ });
232
+ this.eventService.emit({
233
+ kind: 'cache.invalidated',
234
+ sessionId: session.sessionId,
235
+ canonicalPathKey: session.canonicalPathKey,
236
+ revision: session.revision,
237
+ cacheRevision: cacheEntry.revision,
238
+ });
117
239
 
118
240
  return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
119
241
  sessionId: session.sessionId,
@@ -121,26 +243,73 @@ export class AuthoringService {
121
243
  });
122
244
  }
123
245
 
124
- public async saveSession(
125
- input: { sessionId: string; expectedRevision?: number; targetPath?: string },
126
- ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | SavePartialFailureError | PathPolicyViolationError>> {
246
+ public async saveSession(input: SaveSessionInput): Promise<
247
+ BackendResult<
248
+ BackendSessionSnapshot | MaterializeSessionSnapshot,
249
+ | SessionNotFoundError
250
+ | SessionStaleWriteError
251
+ | SavePartialFailureError
252
+ | MaterializeValidationFailedError
253
+ | MaterializeWriteFailedError
254
+ | PathPolicyViolationError
255
+ | InProcessLockConflictError
256
+ | BackendCapabilityUnavailableError
257
+ >
258
+ > {
259
+ if (input.force === true || input.mode === 'materializeCleanSession') {
260
+ return this.materializeSession({
261
+ sessionId: input.sessionId,
262
+ expectedRevision: input.expectedRevision,
263
+ targetPath: input.targetPath,
264
+ fileSystem: input.fileSystem,
265
+ mode: 'fullProject',
266
+ reason: 'force_save',
267
+ });
268
+ }
127
269
  const startedAt = Date.now();
128
270
  const session = this.context.sessions.get(input.sessionId);
129
271
  if (!session || session.closed) {
130
272
  return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
131
273
  }
274
+ const fileSystem = input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
275
+ if (!fileSystem) {
276
+ return failure(
277
+ 'authoring',
278
+ startedAt,
279
+ createCapabilityUnavailableError(
280
+ 'saveSession requires an injected BackendFileSystem adapter.',
281
+ ),
282
+ toSessionSnapshot(session, this.context.capabilities),
283
+ {
284
+ sessionId: session.sessionId,
285
+ revision: session.revision,
286
+ },
287
+ );
288
+ }
132
289
  if (input.expectedRevision !== undefined && input.expectedRevision !== session.revision) {
133
- return failure('authoring', startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
134
- sessionId: session.sessionId,
135
- revision: session.revision,
136
- });
290
+ return failure(
291
+ 'authoring',
292
+ startedAt,
293
+ createStaleWriteError(session, input.expectedRevision),
294
+ toSessionSnapshot(session, this.context.capabilities),
295
+ {
296
+ sessionId: session.sessionId,
297
+ revision: session.revision,
298
+ },
299
+ );
137
300
  }
138
- const targetViolation = await validateSaveTarget(this.context.fileSystem, session.fairyPath, input.targetPath);
301
+ const targetViolation = await validateSaveTarget(fileSystem, session.fairyPath, input.targetPath);
139
302
  if (targetViolation) {
140
- return failure('authoring', startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
141
- sessionId: session.sessionId,
142
- revision: session.revision,
143
- });
303
+ return failure(
304
+ 'authoring',
305
+ startedAt,
306
+ targetViolation,
307
+ toSessionSnapshot(session, this.context.capabilities),
308
+ {
309
+ sessionId: session.sessionId,
310
+ revision: session.revision,
311
+ },
312
+ );
144
313
  }
145
314
  if (!session.dirty) {
146
315
  return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
@@ -151,36 +320,296 @@ export class AuthoringService {
151
320
 
152
321
  const committedPaths: string[] = [];
153
322
  const failedPaths: string[] = [];
154
- this.eventService.emit({ kind: 'save.started', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
323
+ this.eventService.emit({
324
+ kind: 'save.started',
325
+ sessionId: session.sessionId,
326
+ canonicalPathKey: session.canonicalPathKey,
327
+ revision: session.revision,
328
+ });
155
329
  try {
156
- const writer = new ProjectWriter(createWriterFileSystem(this.context.fileSystem, committedPaths, failedPaths));
330
+ const writer = new ProjectWriter(createWriterFileSystem(fileSystem, committedPaths, failedPaths));
157
331
  await writer.write(materializeUamProject(session.project), session.fairyPath);
158
332
  session.lastSavedRevision = session.revision;
159
333
  session.dirty = false;
160
334
  const cacheEntry = this.cacheService.refreshSession(session);
161
- this.eventService.emit({ kind: 'save.completed', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
162
- this.eventService.emit({ kind: 'cache.updated', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, cacheRevision: cacheEntry.revision });
335
+ this.eventService.emit({
336
+ kind: 'save.completed',
337
+ sessionId: session.sessionId,
338
+ canonicalPathKey: session.canonicalPathKey,
339
+ revision: session.revision,
340
+ });
341
+ this.eventService.emit({
342
+ kind: 'cache.updated',
343
+ sessionId: session.sessionId,
344
+ canonicalPathKey: session.canonicalPathKey,
345
+ revision: session.revision,
346
+ cacheRevision: cacheEntry.revision,
347
+ });
163
348
  return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
164
349
  sessionId: session.sessionId,
165
350
  revision: session.revision,
166
351
  });
167
352
  } catch (error) {
168
353
  this.cacheService.invalidateSession(session);
169
- this.eventService.emit({ kind: 'save.failed', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
170
- return failure('authoring', startedAt, {
171
- code: 'save_partial_failure',
172
- message: error instanceof Error ? error.message : String(error),
354
+ this.eventService.emit({
355
+ kind: 'save.failed',
173
356
  sessionId: session.sessionId,
174
357
  canonicalPathKey: session.canonicalPathKey,
175
- attemptedRevision: session.revision,
176
- lastSavedRevision: session.lastSavedRevision,
177
- committedPaths,
178
- failedPaths,
179
- diskMayBePartiallyUpdated: true,
180
- }, toSessionSnapshot(session, this.context.capabilities), {
358
+ revision: session.revision,
359
+ });
360
+ return failure(
361
+ 'authoring',
362
+ startedAt,
363
+ {
364
+ code: 'save_partial_failure',
365
+ message: error instanceof Error ? error.message : String(error),
366
+ sessionId: session.sessionId,
367
+ canonicalPathKey: session.canonicalPathKey,
368
+ attemptedRevision: session.revision,
369
+ lastSavedRevision: session.lastSavedRevision,
370
+ committedPaths,
371
+ failedPaths,
372
+ diskMayBePartiallyUpdated: true,
373
+ },
374
+ toSessionSnapshot(session, this.context.capabilities),
375
+ {
376
+ sessionId: session.sessionId,
377
+ revision: session.revision,
378
+ },
379
+ );
380
+ }
381
+ }
382
+
383
+ public async materializeSession(input: MaterializeSessionInput): Promise<
384
+ BackendResult<
385
+ MaterializeSessionSnapshot,
386
+ | SessionNotFoundError
387
+ | SessionStaleWriteError
388
+ | MaterializeValidationFailedError
389
+ | MaterializeWriteFailedError
390
+ | PathPolicyViolationError
391
+ | InProcessLockConflictError
392
+ | BackendCapabilityUnavailableError
393
+ >
394
+ > {
395
+ const startedAt = Date.now();
396
+ const session = this.context.sessions.get(input.sessionId);
397
+ if (!session || session.closed) {
398
+ return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
399
+ }
400
+ if (input.expectedRevision !== undefined && input.expectedRevision !== session.revision) {
401
+ return failure(
402
+ 'authoring',
403
+ startedAt,
404
+ createStaleWriteError(session, input.expectedRevision),
405
+ toSessionSnapshot(session, this.context.capabilities),
406
+ {
407
+ sessionId: session.sessionId,
408
+ revision: session.revision,
409
+ },
410
+ );
411
+ }
412
+
413
+ const storageTarget = input.storage ? storageCanonicalTarget(input.storage) : null;
414
+ const fileSystem = storageTarget?.fileSystem ?? input.fileSystem ?? session.fileSystem ?? this.context.fileSystem;
415
+ if (!fileSystem) {
416
+ return failure(
417
+ 'authoring',
418
+ startedAt,
419
+ createCapabilityUnavailableError(
420
+ 'materializeSession requires an injected BackendFileSystem adapter.',
421
+ ),
422
+ toSessionSnapshot(session, this.context.capabilities),
423
+ {
424
+ sessionId: session.sessionId,
425
+ revision: session.revision,
426
+ },
427
+ );
428
+ }
429
+
430
+ const fairyPath = storageTarget?.fairyPath ?? session.fairyPath;
431
+ const targetViolation = await validateSaveTarget(fileSystem, fairyPath, input.targetPath);
432
+ if (targetViolation) {
433
+ return failure(
434
+ 'authoring',
435
+ startedAt,
436
+ targetViolation,
437
+ toSessionSnapshot(session, this.context.capabilities),
438
+ {
439
+ sessionId: session.sessionId,
440
+ revision: session.revision,
441
+ },
442
+ );
443
+ }
444
+
445
+ if (storageTarget) {
446
+ const holderSessionId = this.context.sessionsByPath.get(storageTarget.canonicalPathKey);
447
+ if (holderSessionId && holderSessionId !== session.sessionId) {
448
+ return failure(
449
+ 'authoring',
450
+ startedAt,
451
+ {
452
+ code: 'lock_conflict',
453
+ kind: 'in_process_session_exists',
454
+ message: `Project is already open in this backend runtime: ${storageTarget.canonicalProjectPath}`,
455
+ canonicalPathKey: storageTarget.canonicalPathKey,
456
+ holderSessionId,
457
+ },
458
+ toSessionSnapshot(session, this.context.capabilities),
459
+ {
460
+ sessionId: session.sessionId,
461
+ revision: session.revision,
462
+ },
463
+ );
464
+ }
465
+ }
466
+
467
+ const diagnostics = validationDiagnostics(session.project);
468
+ if (diagnostics.length > 0) {
469
+ const error: MaterializeValidationFailedError = {
470
+ code: 'materialize_validation_failed',
471
+ message: `UAM materialize validation failed with ${diagnostics.length} issue(s).`,
472
+ sessionId: session.sessionId,
473
+ canonicalPathKey: session.canonicalPathKey,
474
+ issueCount: diagnostics.length,
475
+ diagnostics,
476
+ };
477
+ return failure(
478
+ 'authoring',
479
+ startedAt,
480
+ error,
481
+ toSessionSnapshot(session, this.context.capabilities),
482
+ {
483
+ sessionId: session.sessionId,
484
+ revision: session.revision,
485
+ diagnostics,
486
+ },
487
+ );
488
+ }
489
+
490
+ let document: ReturnType<typeof materializeUamProject>;
491
+ try {
492
+ document = materializeUamProject(session.project);
493
+ } catch (error) {
494
+ const diagnosticsFromError: BackendDiagnostic[] = [
495
+ {
496
+ code: 'materialize_validation_failed',
497
+ message: error instanceof Error ? error.message : String(error),
498
+ severity: 'error',
499
+ operationKind: 'materializeSession',
500
+ },
501
+ ];
502
+ return failure(
503
+ 'authoring',
504
+ startedAt,
505
+ {
506
+ code: 'materialize_validation_failed',
507
+ message: error instanceof Error ? error.message : String(error),
508
+ sessionId: session.sessionId,
509
+ canonicalPathKey: session.canonicalPathKey,
510
+ issueCount: diagnosticsFromError.length,
511
+ diagnostics: diagnosticsFromError,
512
+ },
513
+ toSessionSnapshot(session, this.context.capabilities),
514
+ {
515
+ sessionId: session.sessionId,
516
+ revision: session.revision,
517
+ diagnostics: diagnosticsFromError,
518
+ },
519
+ );
520
+ }
521
+
522
+ const writtenPaths: string[] = [];
523
+ const failedPaths: string[] = [];
524
+ const skippedPaths: string[] = [];
525
+ this.eventService.emit({
526
+ kind: 'save.started',
527
+ sessionId: session.sessionId,
528
+ canonicalPathKey: storageTarget?.canonicalPathKey ?? session.canonicalPathKey,
529
+ revision: session.revision,
530
+ });
531
+ try {
532
+ const writer = new ProjectWriter(createWriterFileSystem(fileSystem, writtenPaths, failedPaths));
533
+ await writer.write(document, fairyPath);
534
+ if (storageTarget) {
535
+ this.context.sessionsByPath.delete(session.canonicalPathKey);
536
+ session.fileSystem = storageTarget.fileSystem;
537
+ session.fairyPath = storageTarget.fairyPath;
538
+ session.canonicalProjectPath = storageTarget.canonicalProjectPath;
539
+ session.canonicalPathKey = storageTarget.canonicalPathKey;
540
+ this.context.sessionsByPath.set(session.canonicalPathKey, session.sessionId);
541
+ }
542
+ session.lastSavedRevision = session.revision;
543
+ session.dirty = false;
544
+ const cacheEntry = this.cacheService.refreshSession(session);
545
+ this.eventService.emit({
546
+ kind: 'save.completed',
181
547
  sessionId: session.sessionId,
548
+ canonicalPathKey: session.canonicalPathKey,
549
+ revision: session.revision,
550
+ });
551
+ this.eventService.emit({
552
+ kind: 'cache.updated',
553
+ sessionId: session.sessionId,
554
+ canonicalPathKey: session.canonicalPathKey,
555
+ revision: session.revision,
556
+ cacheRevision: cacheEntry.revision,
557
+ });
558
+ return success(
559
+ 'authoring',
560
+ startedAt,
561
+ toMaterializeSnapshot(session, this.context.capabilities, {
562
+ reason: input.reason,
563
+ writtenPaths,
564
+ skippedPaths,
565
+ diagnostics: [],
566
+ }),
567
+ {
568
+ sessionId: session.sessionId,
569
+ revision: session.revision,
570
+ },
571
+ );
572
+ } catch (error) {
573
+ const diagnosticsFromError: BackendDiagnostic[] = [
574
+ {
575
+ code: 'write_failed',
576
+ message: error instanceof Error ? error.message : String(error),
577
+ severity: 'error',
578
+ path: failedPaths[0],
579
+ operationKind: 'materializeSession',
580
+ },
581
+ ];
582
+ this.cacheService.invalidateSession(session);
583
+ this.eventService.emit({
584
+ kind: 'save.failed',
585
+ sessionId: session.sessionId,
586
+ canonicalPathKey: session.canonicalPathKey,
182
587
  revision: session.revision,
588
+ diagnostics: diagnosticsFromError,
183
589
  });
590
+ return failure(
591
+ 'authoring',
592
+ startedAt,
593
+ {
594
+ code: 'write_failed',
595
+ message: error instanceof Error ? error.message : String(error),
596
+ sessionId: session.sessionId,
597
+ canonicalPathKey: session.canonicalPathKey,
598
+ attemptedRevision: session.revision,
599
+ lastSavedRevision: session.lastSavedRevision,
600
+ writtenPaths,
601
+ failedPaths,
602
+ skippedPaths,
603
+ diagnostics: diagnosticsFromError,
604
+ diskMayBePartiallyUpdated: true,
605
+ },
606
+ toSessionSnapshot(session, this.context.capabilities),
607
+ {
608
+ sessionId: session.sessionId,
609
+ revision: session.revision,
610
+ diagnostics: diagnosticsFromError,
611
+ },
612
+ );
184
613
  }
185
614
  }
186
615
  }
@@ -5,6 +5,7 @@ import type {
5
5
  BackendEvent,
6
6
  BackendFailure,
7
7
  BackendFileSystem,
8
+ BackendHostAdapter,
8
9
  BackendJobSnapshot,
9
10
  BackendSessionSnapshot,
10
11
  BackendSuccess,
@@ -24,7 +25,8 @@ export interface BackendSessionState {
24
25
  canonicalProjectPath: string;
25
26
  canonicalPathKey: string;
26
27
  lockFilePath: string;
27
- project: import('@openfairygui/core').UamProject;
28
+ fileSystem?: BackendFileSystem;
29
+ project: import('@openfairygui/core/uam').UamProject;
28
30
  revision: number;
29
31
  lastSavedRevision: number;
30
32
  dirty: boolean;
@@ -33,7 +35,8 @@ export interface BackendSessionState {
33
35
  }
34
36
 
35
37
  export interface BackendContext {
36
- fileSystem: BackendFileSystem;
38
+ fileSystem?: BackendFileSystem;
39
+ host?: BackendHostAdapter;
37
40
  capabilities: BackendCapabilities;
38
41
  sessions: Map<string, BackendSessionState>;
39
42
  sessionsByPath: Map<string, string>;
@@ -43,6 +46,14 @@ export interface BackendContext {
43
46
  nextEventSequence: () => number;
44
47
  }
45
48
 
49
+ function diagnosticFromError(error: BackendError): BackendDiagnostic {
50
+ return {
51
+ code: error.code,
52
+ message: error.message,
53
+ severity: 'error',
54
+ };
55
+ }
56
+
46
57
  function randomId(): string {
47
58
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
48
59
  }
@@ -103,9 +114,10 @@ export function failure<E extends BackendError>(
103
114
  diagnostics?: BackendDiagnostic[];
104
115
  },
105
116
  ): BackendFailure<E> {
117
+ const diagnostics = options?.diagnostics ?? [diagnosticFromError(error)];
106
118
  return {
107
119
  ok: false,
108
- meta: createMeta(stage, startedAt, options),
120
+ meta: createMeta(stage, startedAt, { ...options, diagnostics }),
109
121
  error,
110
122
  session,
111
123
  };
@@ -51,7 +51,7 @@ export class EventService {
51
51
  after: String(input.after),
52
52
  });
53
53
  }
54
- if (events.length > 0 && after < oldestSequence - 1) {
54
+ if (events.length > 0 && after !== 0 && after < oldestSequence - 1) {
55
55
  return failure('runtime', startedAt, {
56
56
  code: 'event_cursor_invalid',
57
57
  message: `Event cursor has expired: ${after}`,