@openfairygui/backend 0.2.0-alpha.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.
@@ -0,0 +1,186 @@
1
+ import { materializeUamProject, ProjectWriter } from '@openfairygui/core';
2
+ import { applyUamTransactionApp, type ApplyUamTransactionAppError } from '@openfairygui/functions';
3
+ import type { CacheService } from './cache-service.js';
4
+ import { failure, success, type BackendContext } from './context.js';
5
+ import type { EventService } from './event-service.js';
6
+ import type {
7
+ ApplySessionTransactionInput,
8
+ BackendFileSystem,
9
+ BackendResult,
10
+ BackendSessionSnapshot,
11
+ SavePartialFailureError,
12
+ SessionNotFoundError,
13
+ SessionStaleWriteError,
14
+ } from '../runtime.js';
15
+ import { validateSaveTarget, type PathPolicyViolationError } from '../path-policy.js';
16
+ import { createSessionNotFoundError, createStaleWriteError, toSessionSnapshot } from './session-utils.js';
17
+
18
+ function createWriterFileSystem(
19
+ fileSystem: BackendFileSystem,
20
+ committedPaths: string[],
21
+ failedPaths: string[],
22
+ ): import('@openfairygui/core').FileSystem {
23
+ async function trackWrite<T>(targetPath: string, fn: () => Promise<T>): Promise<T> {
24
+ try {
25
+ const result = await fn();
26
+ committedPaths.push(targetPath);
27
+ return result;
28
+ } catch (error) {
29
+ failedPaths.push(targetPath);
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ return {
35
+ async readFile(filePath: string): Promise<string> {
36
+ return fileSystem.readFile(filePath);
37
+ },
38
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
39
+ return fileSystem.readFileRaw(filePath);
40
+ },
41
+ async writeFile(filePath: string, content: string): Promise<void> {
42
+ await trackWrite(filePath, async () => {
43
+ await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
44
+ await fileSystem.writeFile(filePath, content);
45
+ });
46
+ },
47
+ async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
48
+ await trackWrite(filePath, async () => {
49
+ await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
50
+ await fileSystem.writeFileRaw(filePath, data);
51
+ });
52
+ },
53
+ async mkdir(dirPath: string): Promise<void> {
54
+ await fileSystem.mkdir(dirPath, { recursive: true });
55
+ },
56
+ async readdir(dirPath: string): Promise<string[]> {
57
+ return fileSystem.readdir(dirPath);
58
+ },
59
+ async exists(filePath: string): Promise<boolean> {
60
+ try {
61
+ await fileSystem.stat(filePath);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ },
67
+ join(...paths: string[]): string {
68
+ return fileSystem.join(...paths);
69
+ },
70
+ dirname(filePath: string): string {
71
+ return fileSystem.dirname(filePath);
72
+ },
73
+ };
74
+ }
75
+
76
+ export class AuthoringService {
77
+ public constructor(
78
+ private readonly context: BackendContext,
79
+ private readonly cacheService: CacheService,
80
+ private readonly eventService: EventService,
81
+ ) {}
82
+
83
+ public async applyTransaction(
84
+ input: ApplySessionTransactionInput,
85
+ ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError>> {
86
+ const startedAt = Date.now();
87
+ const session = this.context.sessions.get(input.sessionId);
88
+ if (!session || session.closed) {
89
+ return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
90
+ }
91
+ 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), {
94
+ sessionId: session.sessionId,
95
+ revision: session.revision,
96
+ });
97
+ }
98
+
99
+ const result = applyUamTransactionApp({
100
+ project: session.project,
101
+ operations: input.operations,
102
+ });
103
+ 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), {
106
+ sessionId: session.sessionId,
107
+ revision: session.revision,
108
+ });
109
+ }
110
+
111
+ session.project = result.project;
112
+ session.revision += 1;
113
+ session.dirty = true;
114
+ 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 });
117
+
118
+ return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
119
+ sessionId: session.sessionId,
120
+ revision: session.revision,
121
+ });
122
+ }
123
+
124
+ public async saveSession(
125
+ input: { sessionId: string; expectedRevision?: number; targetPath?: string },
126
+ ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | SavePartialFailureError | PathPolicyViolationError>> {
127
+ const startedAt = Date.now();
128
+ const session = this.context.sessions.get(input.sessionId);
129
+ if (!session || session.closed) {
130
+ return failure('authoring', startedAt, createSessionNotFoundError(input.sessionId));
131
+ }
132
+ 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
+ });
137
+ }
138
+ const targetViolation = await validateSaveTarget(this.context.fileSystem, session.fairyPath, input.targetPath);
139
+ if (targetViolation) {
140
+ return failure('authoring', startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
141
+ sessionId: session.sessionId,
142
+ revision: session.revision,
143
+ });
144
+ }
145
+ if (!session.dirty) {
146
+ return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
147
+ sessionId: session.sessionId,
148
+ revision: session.revision,
149
+ });
150
+ }
151
+
152
+ const committedPaths: string[] = [];
153
+ const failedPaths: string[] = [];
154
+ this.eventService.emit({ kind: 'save.started', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision });
155
+ try {
156
+ const writer = new ProjectWriter(createWriterFileSystem(this.context.fileSystem, committedPaths, failedPaths));
157
+ await writer.write(materializeUamProject(session.project), session.fairyPath);
158
+ session.lastSavedRevision = session.revision;
159
+ session.dirty = false;
160
+ 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 });
163
+ return success('authoring', startedAt, toSessionSnapshot(session, this.context.capabilities), {
164
+ sessionId: session.sessionId,
165
+ revision: session.revision,
166
+ });
167
+ } catch (error) {
168
+ 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),
173
+ sessionId: session.sessionId,
174
+ canonicalPathKey: session.canonicalPathKey,
175
+ attemptedRevision: session.revision,
176
+ lastSavedRevision: session.lastSavedRevision,
177
+ committedPaths,
178
+ failedPaths,
179
+ diskMayBePartiallyUpdated: true,
180
+ }, toSessionSnapshot(session, this.context.capabilities), {
181
+ sessionId: session.sessionId,
182
+ revision: session.revision,
183
+ });
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,60 @@
1
+ import { failure, success, type BackendContext, type BackendSessionState } from './context.js';
2
+ import { createSessionNotFoundError } from './session-utils.js';
3
+ import type {
4
+ BackendCacheEntry,
5
+ BackendCacheSnapshot,
6
+ BackendResult,
7
+ GetCacheSnapshotInput,
8
+ SessionNotFoundError,
9
+ } from '../runtime.js';
10
+ import { cloneCacheEntrySnapshot } from './snapshot-utils.js';
11
+
12
+ function createCacheEntry(session: BackendSessionState, valid: boolean): BackendCacheEntry {
13
+ return {
14
+ canonicalPathKey: session.canonicalPathKey,
15
+ sessionId: session.sessionId,
16
+ revision: session.revision,
17
+ lastSavedRevision: session.lastSavedRevision,
18
+ dirty: session.dirty,
19
+ valid,
20
+ indexedAt: new Date().toISOString(),
21
+ summary: {
22
+ packageCount: session.project.packages.length,
23
+ resourceCount: session.project.packages.reduce((total, pkg) => total + pkg.resources.length, 0),
24
+ diagnostics: [],
25
+ },
26
+ };
27
+ }
28
+
29
+ export class CacheService {
30
+ public constructor(private readonly context: BackendContext) {}
31
+
32
+ public getCacheSnapshot(input: GetCacheSnapshotInput): BackendResult<BackendCacheSnapshot, SessionNotFoundError> {
33
+ const startedAt = Date.now();
34
+ const session = this.context.sessions.get(input.sessionId);
35
+ if (!session || session.closed) {
36
+ return failure('read', startedAt, createSessionNotFoundError(input.sessionId));
37
+ }
38
+ const entry = this.context.cacheBySession.get(input.sessionId);
39
+ return success('read', startedAt, {
40
+ cacheRevision: entry?.revision ?? session.revision,
41
+ entries: entry ? [cloneCacheEntrySnapshot(entry)] : [],
42
+ }, { sessionId: session.sessionId, revision: session.revision });
43
+ }
44
+
45
+ public refreshSession(session: BackendSessionState): BackendCacheEntry {
46
+ const entry = createCacheEntry(session, true);
47
+ this.context.cacheBySession.set(session.sessionId, entry);
48
+ return entry;
49
+ }
50
+
51
+ public invalidateSession(session: BackendSessionState): BackendCacheEntry {
52
+ const entry = createCacheEntry(session, false);
53
+ this.context.cacheBySession.set(session.sessionId, entry);
54
+ return entry;
55
+ }
56
+
57
+ public removeSession(sessionId: string): void {
58
+ this.context.cacheBySession.delete(sessionId);
59
+ }
60
+ }
@@ -0,0 +1,112 @@
1
+ import type {
2
+ BackendCacheEntry,
3
+ BackendCapabilities,
4
+ BackendError,
5
+ BackendEvent,
6
+ BackendFailure,
7
+ BackendFileSystem,
8
+ BackendJobSnapshot,
9
+ BackendSessionSnapshot,
10
+ BackendSuccess,
11
+ } from '../runtime.js';
12
+ import {
13
+ BACKEND_CAPABILITY_SCHEMA_VERSION,
14
+ BACKEND_CONTRACT_VERSION,
15
+ type BackendDiagnostic,
16
+ type BackendMessage,
17
+ type BackendResponseMeta,
18
+ type BackendStage,
19
+ } from '../contracts.js';
20
+
21
+ export interface BackendSessionState {
22
+ sessionId: string;
23
+ fairyPath: string;
24
+ canonicalProjectPath: string;
25
+ canonicalPathKey: string;
26
+ lockFilePath: string;
27
+ project: import('@openfairygui/core').UamProject;
28
+ revision: number;
29
+ lastSavedRevision: number;
30
+ dirty: boolean;
31
+ lockHeld: boolean;
32
+ closed: boolean;
33
+ }
34
+
35
+ export interface BackendContext {
36
+ fileSystem: BackendFileSystem;
37
+ capabilities: BackendCapabilities;
38
+ sessions: Map<string, BackendSessionState>;
39
+ sessionsByPath: Map<string, string>;
40
+ eventsBySession: Map<string, BackendEvent[]>;
41
+ jobsBySession: Map<string, BackendJobSnapshot[]>;
42
+ cacheBySession: Map<string, BackendCacheEntry>;
43
+ nextEventSequence: () => number;
44
+ }
45
+
46
+ function randomId(): string {
47
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
48
+ }
49
+
50
+ export function createMeta(
51
+ stage: BackendStage,
52
+ startedAt: number,
53
+ options?: {
54
+ requestId?: string;
55
+ sessionId?: string;
56
+ revision?: number;
57
+ warnings?: BackendMessage[];
58
+ diagnostics?: BackendDiagnostic[];
59
+ },
60
+ ): BackendResponseMeta {
61
+ return {
62
+ requestId: options?.requestId ?? randomId(),
63
+ sessionId: options?.sessionId,
64
+ revision: options?.revision,
65
+ durationMs: Math.max(0, Date.now() - startedAt),
66
+ warnings: options?.warnings ?? [],
67
+ diagnostics: options?.diagnostics ?? [],
68
+ stage,
69
+ contractVersion: BACKEND_CONTRACT_VERSION,
70
+ capabilitySchemaVersion: BACKEND_CAPABILITY_SCHEMA_VERSION,
71
+ };
72
+ }
73
+
74
+ export function success<T>(
75
+ stage: BackendStage,
76
+ startedAt: number,
77
+ data: T,
78
+ options?: {
79
+ requestId?: string;
80
+ sessionId?: string;
81
+ revision?: number;
82
+ warnings?: BackendMessage[];
83
+ diagnostics?: BackendDiagnostic[];
84
+ },
85
+ ): BackendSuccess<T> {
86
+ return {
87
+ ok: true,
88
+ meta: createMeta(stage, startedAt, options),
89
+ data,
90
+ };
91
+ }
92
+
93
+ export function failure<E extends BackendError>(
94
+ stage: BackendStage,
95
+ startedAt: number,
96
+ error: E,
97
+ session?: BackendSessionSnapshot,
98
+ options?: {
99
+ requestId?: string;
100
+ sessionId?: string;
101
+ revision?: number;
102
+ warnings?: BackendMessage[];
103
+ diagnostics?: BackendDiagnostic[];
104
+ },
105
+ ): BackendFailure<E> {
106
+ return {
107
+ ok: false,
108
+ meta: createMeta(stage, startedAt, options),
109
+ error,
110
+ session,
111
+ };
112
+ }
@@ -0,0 +1,84 @@
1
+ import { failure, success, type BackendContext } from './context.js';
2
+ import { createSessionNotFoundError } from './session-utils.js';
3
+ import type {
4
+ BackendEvent,
5
+ BackendResult,
6
+ EventCursorInvalidError,
7
+ GetEventsInput,
8
+ GetEventsSnapshot,
9
+ SessionNotFoundError,
10
+ } from '../runtime.js';
11
+ import { cloneEventSnapshot } from './snapshot-utils.js';
12
+
13
+ const DEFAULT_EVENT_RETENTION_LIMIT = 1000;
14
+
15
+ export class EventService {
16
+ public constructor(private readonly context: BackendContext) {}
17
+
18
+ public emit(event: Omit<BackendEvent, 'sequence' | 'timestamp' | 'diagnostics'> & {
19
+ diagnostics?: BackendEvent['diagnostics'];
20
+ }): BackendEvent {
21
+ const emitted: BackendEvent = {
22
+ ...event,
23
+ sequence: this.context.nextEventSequence(),
24
+ timestamp: new Date().toISOString(),
25
+ diagnostics: event.diagnostics ?? [],
26
+ };
27
+ const sessionId = event.sessionId;
28
+ if (!sessionId) return emitted;
29
+ const events = this.context.eventsBySession.get(sessionId) ?? [];
30
+ events.push(emitted);
31
+ while (events.length > DEFAULT_EVENT_RETENTION_LIMIT) events.shift();
32
+ this.context.eventsBySession.set(sessionId, events);
33
+ return emitted;
34
+ }
35
+
36
+ public getEvents(input: GetEventsInput): BackendResult<GetEventsSnapshot, SessionNotFoundError | EventCursorInvalidError> {
37
+ const startedAt = Date.now();
38
+ const session = this.context.sessions.get(input.sessionId);
39
+ if (!session || session.closed) {
40
+ return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
41
+ }
42
+ const events = this.context.eventsBySession.get(input.sessionId) ?? [];
43
+ const oldestSequence = events[0]?.sequence ?? (events.length === 0 ? 1 : 0);
44
+ const currentSequence = events.at(-1)?.sequence ?? 0;
45
+ const after = input.after === undefined ? 0 : Number(input.after);
46
+ if (!Number.isInteger(after) || after < 0) {
47
+ return failure('runtime', startedAt, {
48
+ code: 'event_cursor_invalid',
49
+ message: `Invalid event cursor: ${input.after}`,
50
+ sessionId: input.sessionId,
51
+ after: String(input.after),
52
+ });
53
+ }
54
+ if (events.length > 0 && after < oldestSequence - 1) {
55
+ return failure('runtime', startedAt, {
56
+ code: 'event_cursor_invalid',
57
+ message: `Event cursor has expired: ${after}`,
58
+ sessionId: input.sessionId,
59
+ after: String(input.after),
60
+ });
61
+ }
62
+ if (after > currentSequence) {
63
+ return failure('runtime', startedAt, {
64
+ code: 'event_cursor_invalid',
65
+ message: `Unknown event cursor: ${after}`,
66
+ sessionId: input.sessionId,
67
+ after: String(input.after),
68
+ });
69
+ }
70
+
71
+ const filtered = events.filter((event) => event.sequence > after);
72
+ const limit = input.limit === undefined ? filtered.length : Math.max(0, input.limit);
73
+ return success('runtime', startedAt, {
74
+ events: filtered.slice(0, limit).map(cloneEventSnapshot),
75
+ oldestSequence,
76
+ currentSequence,
77
+ cursorExpired: false,
78
+ }, { sessionId: session.sessionId, revision: session.revision });
79
+ }
80
+
81
+ public removeSession(sessionId: string): void {
82
+ this.context.eventsBySession.delete(sessionId);
83
+ }
84
+ }
@@ -0,0 +1,239 @@
1
+ import { failure, success, type BackendContext } from './context.js';
2
+ import type { CacheService } from './cache-service.js';
3
+ import type { EventService } from './event-service.js';
4
+ import { createSessionNotFoundError } from './session-utils.js';
5
+ import { cloneJobSnapshot } from './snapshot-utils.js';
6
+ import type {
7
+ BackendJobListSnapshot,
8
+ BackendJobNotCancellableError,
9
+ BackendJobNotFoundError,
10
+ BackendJobSnapshot,
11
+ BackendJobStatus,
12
+ BackendResult,
13
+ CancelJobInput,
14
+ GetJobInput,
15
+ ListJobsInput,
16
+ RefreshCacheInput,
17
+ SessionNotFoundError,
18
+ } from '../runtime.js';
19
+
20
+ const COMPLETED_JOB_RETENTION_LIMIT = 100;
21
+ const REFRESH_START_DELAY_MS = 0;
22
+ const REFRESH_COMPLETE_DELAY_MS = 50;
23
+
24
+ function isTerminal(status: BackendJobStatus): boolean {
25
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
26
+ }
27
+
28
+ export class JobService {
29
+ public constructor(
30
+ private readonly context: BackendContext,
31
+ private readonly cacheService: CacheService,
32
+ private readonly eventService: EventService,
33
+ ) {}
34
+ private readonly cancellationRequests = new Set<string>();
35
+
36
+ private getJobs(sessionId: string): BackendJobSnapshot[] {
37
+ return this.context.jobsBySession.get(sessionId) ?? [];
38
+ }
39
+
40
+ private setJobs(sessionId: string, jobs: BackendJobSnapshot[]): void {
41
+ const retained: BackendJobSnapshot[] = [];
42
+ let terminalCount = 0;
43
+ for (let index = jobs.length - 1; index >= 0; index -= 1) {
44
+ const job = jobs[index];
45
+ if (isTerminal(job.status)) {
46
+ if (terminalCount >= COMPLETED_JOB_RETENTION_LIMIT) continue;
47
+ terminalCount += 1;
48
+ }
49
+ retained.push(job);
50
+ }
51
+ this.context.jobsBySession.set(sessionId, retained.reverse());
52
+ }
53
+
54
+ public refreshCache(input: RefreshCacheInput): BackendResult<BackendJobSnapshot, SessionNotFoundError> {
55
+ const startedAt = Date.now();
56
+ const session = this.context.sessions.get(input.sessionId);
57
+ if (!session || session.closed) {
58
+ return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
59
+ }
60
+ const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
61
+ const createdAt = new Date().toISOString();
62
+ const job: BackendJobSnapshot = {
63
+ jobId,
64
+ kind: 'cache.refresh',
65
+ status: 'queued',
66
+ createdAt,
67
+ sessionId: session.sessionId,
68
+ canonicalPathKey: session.canonicalPathKey,
69
+ revision: session.revision,
70
+ diagnostics: [],
71
+ progress: { completed: 0, total: 1, message: input.reason ?? 'manual' },
72
+ };
73
+ const jobs = this.getJobs(session.sessionId);
74
+ jobs.push(job);
75
+ this.setJobs(session.sessionId, jobs);
76
+ this.eventService.emit({ kind: 'job.created', sessionId: session.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId });
77
+ this.scheduleRefreshJob(session.sessionId, jobId);
78
+
79
+ return success('runtime', startedAt, cloneJobSnapshot(job), { sessionId: session.sessionId, revision: session.revision });
80
+ }
81
+
82
+ public getJob(input: GetJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError> {
83
+ const startedAt = Date.now();
84
+ const session = this.context.sessions.get(input.sessionId);
85
+ if (!session || session.closed) return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
86
+ const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
87
+ if (!job) {
88
+ return failure('runtime', startedAt, {
89
+ code: 'job_not_found',
90
+ message: `Job was not found: ${input.jobId}`,
91
+ sessionId: input.sessionId,
92
+ jobId: input.jobId,
93
+ }, undefined, { sessionId: session.sessionId, revision: session.revision });
94
+ }
95
+ return success('runtime', startedAt, cloneJobSnapshot(job), { sessionId: session.sessionId, revision: session.revision });
96
+ }
97
+
98
+ public listJobs(input: ListJobsInput): BackendResult<BackendJobListSnapshot, SessionNotFoundError> {
99
+ const startedAt = Date.now();
100
+ const session = this.context.sessions.get(input.sessionId);
101
+ if (!session || session.closed) return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
102
+ let jobs = [...this.getJobs(input.sessionId)];
103
+ if (input.kind) jobs = jobs.filter((job) => job.kind === input.kind);
104
+ if (input.status) {
105
+ if (input.status === 'active') jobs = jobs.filter((job) => !isTerminal(job.status));
106
+ else if (input.status === 'terminal') jobs = jobs.filter((job) => isTerminal(job.status));
107
+ else jobs = jobs.filter((job) => job.status === input.status);
108
+ }
109
+ if (input.limit !== undefined) jobs = jobs.slice(0, Math.max(0, input.limit));
110
+ return success('runtime', startedAt, { jobs: jobs.map(cloneJobSnapshot) }, { sessionId: session.sessionId, revision: session.revision });
111
+ }
112
+
113
+ public cancelJob(input: CancelJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError | BackendJobNotCancellableError> {
114
+ const startedAt = Date.now();
115
+ const session = this.context.sessions.get(input.sessionId);
116
+ if (!session || session.closed) return failure('runtime', startedAt, createSessionNotFoundError(input.sessionId));
117
+ const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
118
+ if (!job) {
119
+ return failure('runtime', startedAt, {
120
+ code: 'job_not_found',
121
+ message: `Job was not found: ${input.jobId}`,
122
+ sessionId: input.sessionId,
123
+ jobId: input.jobId,
124
+ }, undefined, { sessionId: session.sessionId, revision: session.revision });
125
+ }
126
+ if (isTerminal(job.status)) {
127
+ return failure('runtime', startedAt, {
128
+ code: 'job_not_cancellable',
129
+ message: `Job is already terminal: ${input.jobId}`,
130
+ sessionId: input.sessionId,
131
+ jobId: input.jobId,
132
+ status: job.status as 'completed' | 'failed' | 'cancelled',
133
+ }, undefined, { sessionId: session.sessionId, revision: session.revision });
134
+ }
135
+ this.cancellationRequests.add(job.jobId);
136
+ this.eventService.emit({ kind: 'job.cancelRequested', sessionId: input.sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId: job.jobId });
137
+ const cancelled = this.cancelRefreshJob(session.sessionId, job);
138
+ return success('runtime', startedAt, cloneJobSnapshot(cancelled), { sessionId: session.sessionId, revision: session.revision });
139
+ }
140
+
141
+ public removeSession(sessionId: string): void {
142
+ for (const job of this.getJobs(sessionId)) this.cancellationRequests.delete(job.jobId);
143
+ this.context.jobsBySession.delete(sessionId);
144
+ }
145
+
146
+ private replaceJob(sessionId: string, nextJob: BackendJobSnapshot): void {
147
+ const jobs = this.getJobs(sessionId).map((job) => job.jobId === nextJob.jobId ? nextJob : job);
148
+ this.setJobs(sessionId, jobs);
149
+ }
150
+
151
+ private scheduleRefreshJob(sessionId: string, jobId: string): void {
152
+ setTimeout(() => this.startRefreshJob(sessionId, jobId), REFRESH_START_DELAY_MS);
153
+ }
154
+
155
+ private startRefreshJob(sessionId: string, jobId: string): void {
156
+ const session = this.context.sessions.get(sessionId);
157
+ if (!session || session.closed) return;
158
+ const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
159
+ if (!job || isTerminal(job.status)) return;
160
+ if (this.cancellationRequests.has(jobId)) {
161
+ this.cancelRefreshJob(sessionId, job);
162
+ return;
163
+ }
164
+ const running: BackendJobSnapshot = {
165
+ ...job,
166
+ status: 'running',
167
+ startedAt: new Date().toISOString(),
168
+ progress: { completed: 0, total: 1, message: 'refreshing cache' },
169
+ };
170
+ this.replaceJob(sessionId, running);
171
+ this.eventService.emit({ kind: 'job.started', sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId });
172
+ this.eventService.emit({ kind: 'job.progress', sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId, payload: running.progress });
173
+ setTimeout(() => this.completeRefreshJob(sessionId, jobId), REFRESH_COMPLETE_DELAY_MS);
174
+ }
175
+
176
+ private completeRefreshJob(sessionId: string, jobId: string): void {
177
+ const session = this.context.sessions.get(sessionId);
178
+ if (!session || session.closed) return;
179
+ const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
180
+ if (!job || isTerminal(job.status)) return;
181
+ if (this.cancellationRequests.has(jobId)) {
182
+ this.cancelRefreshJob(sessionId, job);
183
+ return;
184
+ }
185
+ try {
186
+ const entry = this.cacheService.refreshSession(session);
187
+ const completed: BackendJobSnapshot = {
188
+ ...job,
189
+ status: 'completed',
190
+ finishedAt: new Date().toISOString(),
191
+ cacheRevision: entry.revision,
192
+ progress: { completed: 1, total: 1, message: 'cache refreshed' },
193
+ result: { cacheRevision: entry.revision },
194
+ };
195
+ this.replaceJob(sessionId, completed);
196
+ this.eventService.emit({ kind: 'job.completed', sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, cacheRevision: entry.revision, jobId });
197
+ this.eventService.emit({ kind: 'cache.updated', sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, cacheRevision: entry.revision });
198
+ } catch (error) {
199
+ const failed: BackendJobSnapshot = {
200
+ ...job,
201
+ status: 'failed',
202
+ finishedAt: new Date().toISOString(),
203
+ error: {
204
+ code: 'cache_refresh_failed',
205
+ message: error instanceof Error ? error.message : 'Cache refresh failed',
206
+ sessionId,
207
+ jobId,
208
+ },
209
+ };
210
+ this.replaceJob(sessionId, failed);
211
+ this.eventService.emit({ kind: 'job.failed', sessionId, canonicalPathKey: session.canonicalPathKey, revision: session.revision, jobId, diagnostics: failed.diagnostics });
212
+ }
213
+ }
214
+
215
+ private cancelRefreshJob(sessionId: string, job: BackendJobSnapshot): BackendJobSnapshot {
216
+ const session = this.context.sessions.get(sessionId);
217
+ const cancelled: BackendJobSnapshot = {
218
+ ...job,
219
+ status: 'cancelled',
220
+ finishedAt: new Date().toISOString(),
221
+ error: {
222
+ code: 'job_cancelled',
223
+ message: `Job was cancelled: ${job.jobId}`,
224
+ sessionId,
225
+ jobId: job.jobId,
226
+ },
227
+ };
228
+ this.replaceJob(sessionId, cancelled);
229
+ this.cancellationRequests.delete(job.jobId);
230
+ this.eventService.emit({
231
+ kind: 'job.cancelled',
232
+ sessionId,
233
+ canonicalPathKey: session?.canonicalPathKey ?? job.canonicalPathKey,
234
+ revision: session?.revision ?? job.revision,
235
+ jobId: job.jobId,
236
+ });
237
+ return cancelled;
238
+ }
239
+ }