@gmickel/gno 1.17.0 → 1.18.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.
Files changed (51) hide show
  1. package/README.md +14 -2
  2. package/assets/skill/SKILL.md +17 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +2 -2
  5. package/src/cli/commands/daemon.ts +69 -2
  6. package/src/cli/commands/models/pull.ts +13 -3
  7. package/src/cli/commands/status.ts +2 -0
  8. package/src/cli/detach.ts +37 -20
  9. package/src/cli/program.ts +74 -27
  10. package/src/config/index.ts +3 -0
  11. package/src/config/types.ts +37 -0
  12. package/src/core/job-manager.ts +19 -0
  13. package/src/core/mutation-generations.ts +33 -0
  14. package/src/llm/cache.ts +13 -3
  15. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  16. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  17. package/src/mcp/context.ts +161 -0
  18. package/src/mcp/http-security.ts +477 -0
  19. package/src/mcp/http-session.ts +272 -0
  20. package/src/mcp/http-transport.ts +370 -0
  21. package/src/mcp/resources/index.ts +141 -134
  22. package/src/mcp/server.ts +19 -79
  23. package/src/mcp/tools/add-collection.ts +3 -1
  24. package/src/mcp/tools/capture.ts +3 -0
  25. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  26. package/src/mcp/tools/context.ts +9 -8
  27. package/src/mcp/tools/embed.ts +62 -52
  28. package/src/mcp/tools/index-cmd.ts +88 -74
  29. package/src/mcp/tools/index.ts +22 -2
  30. package/src/mcp/tools/remove-collection.ts +2 -0
  31. package/src/mcp/tools/status.ts +11 -0
  32. package/src/mcp/tools/sync.ts +16 -14
  33. package/src/mcp/tools/workspace-write.ts +7 -3
  34. package/src/serve/background-runtime.ts +12 -212
  35. package/src/serve/embed-scheduler.ts +74 -43
  36. package/src/serve/index.ts +9 -0
  37. package/src/serve/jobs.ts +78 -80
  38. package/src/serve/public/components/HealthCenter.tsx +74 -1
  39. package/src/serve/public/globals.built.css +1 -1
  40. package/src/serve/public/pages/Dashboard.tsx +1 -0
  41. package/src/serve/resident-admission.ts +159 -0
  42. package/src/serve/resident-background-work.ts +39 -0
  43. package/src/serve/resident-request.ts +55 -0
  44. package/src/serve/resident-runtime.ts +490 -0
  45. package/src/serve/resident-status.ts +96 -0
  46. package/src/serve/routes/api.ts +263 -167
  47. package/src/serve/routes/mcp.ts +69 -0
  48. package/src/serve/server.ts +191 -37
  49. package/src/serve/status-model.ts +51 -0
  50. package/src/serve/status.ts +5 -0
  51. package/src/store/sqlite/adapter.ts +26 -9
@@ -1,46 +1,20 @@
1
- // node:path resolve has no Bun equivalent for canonical process-relative paths.
2
- import { resolve } from "node:path";
1
+ /** Backwards-compatible entrypoint for the shared resident runtime. */
3
2
 
4
3
  import type { Config } from "../config/types";
5
4
  import type { SyncResult } from "../ingestion";
6
- import type { DocumentEventBus } from "./doc-events";
5
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
7
6
  import type { EmbedResult, EmbedScheduler } from "./embed-scheduler";
8
7
  import type { ContextHolder } from "./routes/api";
9
- import type {
10
- CollectionWatchCallbacks,
11
- CollectionWatchService,
12
- } from "./watch-service";
8
+ import type { CollectionWatchService } from "./watch-service";
13
9
 
14
- import { getIndexDbPath } from "../app/constants";
15
- import { INDEX_NAME_REQUIREMENTS, isValidIndexName } from "../app/index-name";
16
10
  import {
17
- ensureDirectories,
18
- formatConfigWarnings,
19
- getConfigPaths,
20
- isInitialized,
21
- loadConfig,
22
- } from "../config";
23
- import { defaultSyncService } from "../ingestion";
24
- import { withContentTypeRules } from "../ingestion";
25
- import { getActivePreset } from "../llm/registry";
26
- import { SqliteAdapter } from "../store/sqlite/adapter";
27
- import {
28
- createServerContext,
29
- type CreateServerContextOptions,
30
- disposeServerContext,
31
- type ServerContext,
32
- } from "./context";
33
- import { createEmbedScheduler } from "./embed-scheduler";
34
- import { CollectionWatchService as DefaultCollectionWatchService } from "./watch-service";
11
+ startResidentRuntime,
12
+ type ResidentRuntimeDeps,
13
+ type ResidentRuntimeOptions,
14
+ } from "./resident-runtime";
35
15
 
36
- export interface BackgroundRuntimeOptions {
37
- configPath?: string;
38
- index?: string;
39
- requireCollections?: boolean;
40
- offline?: boolean;
41
- eventBus?: DocumentEventBus | null;
42
- watchCallbacks?: CollectionWatchCallbacks;
43
- }
16
+ export type BackgroundRuntimeOptions = ResidentRuntimeOptions;
17
+ export type BackgroundRuntimeDeps = ResidentRuntimeDeps;
44
18
 
45
19
  export interface BackgroundRuntime {
46
20
  store: SqliteAdapter;
@@ -48,16 +22,13 @@ export interface BackgroundRuntime {
48
22
  actualConfigPath: string;
49
23
  ctxHolder: ContextHolder;
50
24
  scheduler: EmbedScheduler;
51
- eventBus: DocumentEventBus | null;
25
+ eventBus: import("./doc-events").DocumentEventBus | null;
52
26
  watchService: CollectionWatchService;
53
27
  syncAll(options?: {
54
28
  gitPull?: boolean;
55
29
  runUpdateCmd?: boolean;
56
30
  triggerEmbed?: boolean;
57
- }): Promise<{
58
- syncResult: SyncResult;
59
- embedResult: EmbedResult | null;
60
- }>;
31
+ }): Promise<{ syncResult: SyncResult; embedResult: EmbedResult | null }>;
61
32
  dispose(): Promise<void>;
62
33
  }
63
34
 
@@ -65,180 +36,9 @@ export type BackgroundRuntimeResult =
65
36
  | { success: true; runtime: BackgroundRuntime }
66
37
  | { success: false; error: string };
67
38
 
68
- type BackgroundRuntimeDeps = {
69
- isInitialized?: typeof isInitialized;
70
- loadConfig?: typeof loadConfig;
71
- getConfigPaths?: typeof getConfigPaths;
72
- ensureDirectories?: typeof ensureDirectories;
73
- storeFactory?: () => SqliteAdapter;
74
- createServerContext?: (
75
- store: SqliteAdapter,
76
- config: Config,
77
- options?: CreateServerContextOptions
78
- ) => Promise<ServerContext>;
79
- disposeServerContext?: (ctx: ServerContext) => Promise<void>;
80
- createEmbedScheduler?: typeof createEmbedScheduler;
81
- syncAllService?: typeof defaultSyncService.syncAll;
82
- watchServiceFactory?: (options: {
83
- collections: Config["collections"];
84
- store: SqliteAdapter;
85
- scheduler: EmbedScheduler | null;
86
- eventBus?: DocumentEventBus | null;
87
- callbacks?: CollectionWatchCallbacks;
88
- syncOptions?: Parameters<typeof withContentTypeRules>[0];
89
- }) => CollectionWatchService;
90
- };
91
-
92
39
  export async function startBackgroundRuntime(
93
40
  options: BackgroundRuntimeOptions = {},
94
41
  deps: BackgroundRuntimeDeps = {}
95
42
  ): Promise<BackgroundRuntimeResult> {
96
- if (options.index !== undefined && !isValidIndexName(options.index)) {
97
- return {
98
- success: false,
99
- error: `Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`,
100
- };
101
- }
102
- const syncAllService = deps.syncAllService
103
- ? (...args: Parameters<typeof defaultSyncService.syncAll>) =>
104
- deps.syncAllService!(...args)
105
- : defaultSyncService.syncAll.bind(defaultSyncService);
106
- const initialized = await (deps.isInitialized ?? isInitialized)(
107
- options.configPath
108
- );
109
- if (!initialized) {
110
- return { success: false, error: "GNO not initialized. Run: gno init" };
111
- }
112
-
113
- const configResult = await (deps.loadConfig ?? loadConfig)(
114
- options.configPath
115
- );
116
- if (!configResult.ok) {
117
- return { success: false, error: configResult.error.message };
118
- }
119
- for (const warning of formatConfigWarnings(configResult.warnings)) {
120
- console.warn(warning);
121
- }
122
- const config = configResult.value;
123
-
124
- if (options.requireCollections && config.collections.length === 0) {
125
- return {
126
- success: false,
127
- error: "No collections configured. Run: gno collection add <path>",
128
- };
129
- }
130
-
131
- await (deps.ensureDirectories ?? ensureDirectories)();
132
-
133
- const store = deps.storeFactory ? deps.storeFactory() : new SqliteAdapter();
134
- const dbPath = getIndexDbPath(options.index);
135
- const paths = (deps.getConfigPaths ?? getConfigPaths)();
136
- const actualConfigPath = resolve(options.configPath ?? paths.configFile);
137
- store.setConfigPath(actualConfigPath);
138
-
139
- const openResult = await store.open(dbPath, config.ftsTokenizer);
140
- if (!openResult.ok) {
141
- return { success: false, error: openResult.error.message };
142
- }
143
-
144
- const syncCollResult = await store.syncCollections(config.collections);
145
- if (!syncCollResult.ok) {
146
- await store.close();
147
- return { success: false, error: syncCollResult.error.message };
148
- }
149
- const syncCtxResult = await store.syncContexts(config.contexts ?? []);
150
- if (!syncCtxResult.ok) {
151
- await store.close();
152
- return { success: false, error: syncCtxResult.error.message };
153
- }
154
-
155
- const ctx = await (deps.createServerContext ?? createServerContext)(
156
- store,
157
- config,
158
- {
159
- offline: options.offline ?? false,
160
- indexName: options.index,
161
- }
162
- );
163
-
164
- const ctxHolder: ContextHolder = {
165
- current: ctx,
166
- config,
167
- scheduler: null,
168
- eventBus: options.eventBus ?? null,
169
- watchService: null,
170
- };
171
-
172
- const scheduler = (deps.createEmbedScheduler ?? createEmbedScheduler)({
173
- db: store.getRawDb(),
174
- getEmbedPort: () => ctxHolder.current.embedPort,
175
- getVectorIndex: () => ctxHolder.current.vectorIndex,
176
- getModelUri: () => getActivePreset(ctxHolder.config).embed,
177
- });
178
- ctxHolder.scheduler = scheduler;
179
- ctxHolder.current.scheduler = scheduler;
180
- ctxHolder.current.eventBus = options.eventBus ?? null;
181
-
182
- const watchService = (
183
- deps.watchServiceFactory ??
184
- ((watchOptions) => new DefaultCollectionWatchService(watchOptions))
185
- )({
186
- collections: config.collections,
187
- store,
188
- scheduler,
189
- eventBus: options.eventBus ?? null,
190
- callbacks: options.watchCallbacks,
191
- syncOptions: withContentTypeRules({}, config),
192
- });
193
- watchService.start();
194
- ctxHolder.watchService = watchService;
195
- ctxHolder.current.watchService = watchService;
196
-
197
- let disposed = false;
198
-
199
- return {
200
- success: true,
201
- runtime: {
202
- store,
203
- config,
204
- actualConfigPath,
205
- ctxHolder,
206
- scheduler,
207
- eventBus: options.eventBus ?? null,
208
- watchService,
209
- async syncAll(syncOptions = {}) {
210
- const syncResult = await syncAllService(
211
- config.collections,
212
- store,
213
- withContentTypeRules(
214
- {
215
- gitPull: syncOptions.gitPull,
216
- runUpdateCmd: syncOptions.runUpdateCmd,
217
- },
218
- config
219
- )
220
- );
221
-
222
- let embedResult: EmbedResult | null = null;
223
- if (syncOptions.triggerEmbed !== false) {
224
- embedResult = await scheduler.triggerNow();
225
- }
226
-
227
- return { syncResult, embedResult };
228
- },
229
- async dispose() {
230
- if (disposed) {
231
- return;
232
- }
233
- disposed = true;
234
- await Promise.resolve(watchService.dispose());
235
- options.eventBus?.close();
236
- scheduler.dispose();
237
- await (deps.disposeServerContext ?? disposeServerContext)(
238
- ctxHolder.current
239
- );
240
- await store.close();
241
- },
242
- },
243
- };
43
+ return startResidentRuntime(options, deps);
244
44
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
+ import type { ModelLease } from "../llm/nodeLlamaCpp/lifecycle";
10
11
  import type { EmbeddingPort } from "../llm/types";
11
12
  import type { VectorIndexPort } from "../store/vector";
12
13
 
@@ -46,6 +47,9 @@ export interface EmbedSchedulerDeps {
46
47
  getVectorIndex: () => VectorIndexPort | null;
47
48
  /** Getter for current model URI (survives preset changes) */
48
49
  getModelUri: () => string;
50
+ acquireModelLease?: () => ModelLease;
51
+ onEmbedded?: (result: EmbedResult) => void;
52
+ embedBacklogFn?: typeof embedBacklog;
49
53
  }
50
54
 
51
55
  // ─────────────────────────────────────────────────────────────────────────────
@@ -63,7 +67,7 @@ export interface EmbedScheduler {
63
67
  getState(): EmbedSchedulerState;
64
68
 
65
69
  /** Cleanup on server shutdown */
66
- dispose(): void;
70
+ dispose(): Promise<void>;
67
71
  }
68
72
 
69
73
  /**
@@ -71,7 +75,15 @@ export interface EmbedScheduler {
71
75
  * Uses getters to resolve dependencies at execution time (survives context reloads).
72
76
  */
73
77
  export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
74
- const { db, getEmbedPort, getVectorIndex, getModelUri } = deps;
78
+ const {
79
+ db,
80
+ getEmbedPort,
81
+ getVectorIndex,
82
+ getModelUri,
83
+ acquireModelLease,
84
+ onEmbedded,
85
+ embedBacklogFn = embedBacklog,
86
+ } = deps;
75
87
 
76
88
  // State
77
89
  let pendingCount = 0; // Track pending triggers (not actual docIds - we embed full backlog)
@@ -81,6 +93,7 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
81
93
  let firstPendingAt: number | null = null;
82
94
  let nextRunAt: number | null = null; // Accurate timer due time
83
95
  let disposed = false;
96
+ let currentRun: Promise<EmbedResult | null> | null = null;
84
97
  let lastRunAt: number | null = null;
85
98
  let lastResult: EmbedResult | null = null;
86
99
 
@@ -103,20 +116,25 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
103
116
  return { embedded: 0, errors: 0 };
104
117
  }
105
118
 
106
- const result = await embedBacklog({
107
- statsPort: stats,
108
- embedPort,
109
- vectorIndex,
110
- modelUri,
111
- batchSize: BATCH_SIZE,
112
- });
113
-
114
- if (!result.ok) {
115
- console.error("[embed-scheduler] Embed failed:", result.error.message);
116
- return { embedded: 0, errors: 0 };
119
+ const lease = acquireModelLease?.();
120
+ try {
121
+ const result = await embedBacklogFn({
122
+ statsPort: stats,
123
+ embedPort,
124
+ vectorIndex,
125
+ modelUri,
126
+ batchSize: BATCH_SIZE,
127
+ });
128
+
129
+ if (!result.ok) {
130
+ console.error("[embed-scheduler] Embed failed:", result.error.message);
131
+ return { embedded: 0, errors: 0 };
132
+ }
133
+ if (result.value.embedded > 0) onEmbedded?.(result.value);
134
+ return result.value;
135
+ } finally {
136
+ lease?.release();
117
137
  }
118
-
119
- return result.value;
120
138
  }
121
139
 
122
140
  /**
@@ -166,41 +184,53 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
166
184
  /**
167
185
  * Execute the embed run with concurrency guard.
168
186
  */
169
- async function executeRun(): Promise<EmbedResult | null> {
187
+ function executeRun(): Promise<EmbedResult | null> {
170
188
  if (disposed || running) {
171
189
  needsRerun = true;
172
- return null;
190
+ return Promise.resolve(null);
173
191
  }
174
192
 
175
- running = true;
176
- timer = null;
177
- nextRunAt = null;
178
-
179
- // Clear pending state at START so new notifications accumulate
180
- pendingCount = 0;
181
- firstPendingAt = null;
193
+ const operation = (async (): Promise<EmbedResult | null> => {
194
+ running = true;
195
+ timer = null;
196
+ nextRunAt = null;
182
197
 
183
- let result: EmbedResult;
184
- try {
185
- result = await runEmbed();
186
- lastRunAt = Date.now();
187
- lastResult = result;
188
- } finally {
189
- running = false;
190
- }
198
+ // Clear pending state at START so new notifications accumulate
199
+ pendingCount = 0;
200
+ firstPendingAt = null;
201
+
202
+ let result: EmbedResult;
203
+ try {
204
+ result = await runEmbed();
205
+ lastRunAt = Date.now();
206
+ lastResult = result;
207
+ } finally {
208
+ running = false;
209
+ }
191
210
 
192
- // Check if we need to rerun (notifications arrived while running)
193
- // Must be AFTER running=false so scheduleRun() actually schedules
194
- if ((needsRerun || pendingCount > 0) && !disposed) {
195
- needsRerun = false;
196
- // Set firstPendingAt if we have pending work
197
- if (pendingCount > 0 && firstPendingAt === null) {
198
- firstPendingAt = Date.now();
211
+ // Check if we need to rerun (notifications arrived while running)
212
+ // Must be AFTER running=false so scheduleRun() actually schedules
213
+ if ((needsRerun || pendingCount > 0) && !disposed) {
214
+ needsRerun = false;
215
+ // Set firstPendingAt if we have pending work
216
+ if (pendingCount > 0 && firstPendingAt === null) {
217
+ firstPendingAt = Date.now();
218
+ }
219
+ scheduleRun();
199
220
  }
200
- scheduleRun();
201
- }
202
221
 
203
- return result;
222
+ return result;
223
+ })();
224
+ currentRun = operation;
225
+ void operation.then(
226
+ () => {
227
+ if (currentRun === operation) currentRun = null;
228
+ },
229
+ () => {
230
+ if (currentRun === operation) currentRun = null;
231
+ }
232
+ );
233
+ return operation;
204
234
  }
205
235
 
206
236
  return {
@@ -263,13 +293,14 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
263
293
  return state;
264
294
  },
265
295
 
266
- dispose(): void {
296
+ async dispose(): Promise<void> {
267
297
  disposed = true;
268
298
  if (timer) {
269
299
  clearTimeout(timer);
270
300
  timer = null;
271
301
  nextRunAt = null;
272
302
  }
303
+ await currentRun;
273
304
  },
274
305
  };
275
306
  }
@@ -11,3 +11,12 @@ export {
11
11
  type BackgroundRuntimeResult,
12
12
  startBackgroundRuntime,
13
13
  } from "./background-runtime";
14
+ export {
15
+ type ResidentGeneration,
16
+ type ResidentMode,
17
+ type ResidentRequestHandle,
18
+ type ResidentRuntime,
19
+ type ResidentRuntimeOptions,
20
+ type ResidentRuntimeResult,
21
+ startResidentRuntime,
22
+ } from "./resident-runtime";
package/src/serve/jobs.ts CHANGED
@@ -1,13 +1,8 @@
1
- /**
2
- * Background job tracker for API write operations.
3
- * Simple in-memory tracking with global mutex (one job at a time).
4
- *
5
- * @module src/serve/jobs
6
- */
1
+ /** REST-compatible facade over the resident JobManager. */
7
2
 
3
+ import type { JobManager, JobRecord } from "../core/job-manager";
8
4
  import type { SyncResult } from "../ingestion";
9
5
 
10
- // Job expiration: 1 hour
11
6
  const JOB_EXPIRATION_MS = 60 * 60 * 1000;
12
7
 
13
8
  export type JobType = "add" | "sync" | "embed";
@@ -42,44 +37,60 @@ export interface StartJobError {
42
37
 
43
38
  export type StartJobResult = StartJobSuccess | StartJobError;
44
39
 
45
- // Global state - only one job can run at a time
40
+ // Standalone fallback retained for unit fixtures that do not construct a resident.
46
41
  let activeJobId: string | null = null;
47
42
  const jobs = new Map<string, JobStatus>();
48
43
 
49
- /**
50
- * Clean up expired jobs to prevent memory leaks.
51
- * Called on every job access.
52
- * Only expires completed/failed jobs - running jobs are never expired.
53
- */
44
+ const fromResidentRecord = (record: JobRecord): JobStatus => ({
45
+ id: record.id,
46
+ type: record.type as JobType,
47
+ status: record.status,
48
+ createdAt: record.startedAt,
49
+ progress: record.progress,
50
+ result: record.result,
51
+ error: record.error,
52
+ });
53
+
54
54
  function cleanupExpiredJobs(now = Date.now()): void {
55
55
  for (const [id, job] of jobs) {
56
- // Never expire running jobs - only completed/failed
57
- if (job.status === "running") {
58
- continue;
59
- }
60
- if (now - job.createdAt > JOB_EXPIRATION_MS) {
56
+ if (job.status !== "running" && now - job.createdAt > JOB_EXPIRATION_MS) {
61
57
  jobs.delete(id);
62
58
  }
63
59
  }
64
60
  }
65
61
 
66
- /**
67
- * Start a background job.
68
- * Returns immediately with jobId; caller polls /api/jobs/:id for status.
69
- * Use updateJobProgress() to update progress during execution.
70
- *
71
- * @param type - Job type identifier
72
- * @param fn - Async function to run in background
73
- * @returns Job ID on success, or 409 error if job already running
74
- */
75
62
  export function startJob(
76
63
  type: JobType,
77
64
  fn: () => Promise<SyncResult>
78
- ): StartJobResult {
79
- // Cleanup expired jobs first
80
- cleanupExpiredJobs();
65
+ ): StartJobResult;
66
+ export function startJob(
67
+ type: JobType,
68
+ fn: () => Promise<SyncResult>,
69
+ manager: JobManager
70
+ ): Promise<StartJobResult>;
71
+ export function startJob(
72
+ type: JobType,
73
+ fn: () => Promise<SyncResult>,
74
+ manager: JobManager | undefined
75
+ ): StartJobResult | Promise<StartJobResult>;
76
+ export function startJob(
77
+ type: JobType,
78
+ fn: () => Promise<SyncResult>,
79
+ manager?: JobManager
80
+ ): StartJobResult | Promise<StartJobResult> {
81
+ if (manager) {
82
+ return manager.startJob(type, fn).then(
83
+ (jobId) => ({ ok: true, jobId }),
84
+ (error: unknown) => ({
85
+ ok: false,
86
+ error: error instanceof Error ? error.message : String(error),
87
+ status: 409,
88
+ activeJobId: manager.getActiveJob()?.id ?? "",
89
+ })
90
+ );
91
+ }
81
92
 
82
- // Check if a job is already running
93
+ cleanupExpiredJobs();
83
94
  if (activeJobId) {
84
95
  return {
85
96
  ok: false,
@@ -88,21 +99,14 @@ export function startJob(
88
99
  activeJobId,
89
100
  };
90
101
  }
91
-
92
102
  const jobId = crypto.randomUUID();
93
103
  activeJobId = jobId;
94
-
95
- const jobStatus: JobStatus = {
104
+ jobs.set(jobId, {
96
105
  id: jobId,
97
106
  type,
98
107
  status: "running",
99
108
  createdAt: Date.now(),
100
- };
101
- jobs.set(jobId, jobStatus);
102
-
103
- // Run in background (don't await)
104
- // Wrap with Promise.resolve().then() to catch sync throws from fn()
105
- // Without this, a sync throw would leave activeJobId set forever (deadlock)
109
+ });
106
110
  Promise.resolve()
107
111
  .then(fn)
108
112
  .then((result) => {
@@ -112,73 +116,67 @@ export function startJob(
112
116
  job.result = result;
113
117
  }
114
118
  })
115
- .catch((e) => {
119
+ .catch((error: unknown) => {
116
120
  const job = jobs.get(jobId);
117
121
  if (job) {
118
122
  job.status = "failed";
119
- job.error = e instanceof Error ? e.message : String(e);
123
+ job.error = error instanceof Error ? error.message : String(error);
120
124
  }
121
125
  })
122
126
  .finally(() => {
123
127
  activeJobId = null;
124
128
  });
125
-
126
129
  return { ok: true, jobId };
127
130
  }
128
131
 
129
- /**
130
- * Get current status of a job.
131
- *
132
- * @param jobId - Job ID from startJob
133
- * @returns Job status or undefined if not found
134
- */
135
- export function getJobStatus(jobId: string): JobStatus | undefined {
132
+ export function getJobStatus(
133
+ jobId: string,
134
+ manager?: JobManager
135
+ ): JobStatus | undefined {
136
+ if (manager) {
137
+ const record = manager.getJob(jobId);
138
+ return record ? fromResidentRecord(record) : undefined;
139
+ }
136
140
  cleanupExpiredJobs();
137
141
  return jobs.get(jobId);
138
142
  }
139
143
 
140
- /**
141
- * Get the currently active job ID, if any.
142
- */
143
- export function getActiveJobId(): string | null {
144
- return activeJobId;
144
+ export function getActiveJobId(manager?: JobManager): string | null {
145
+ return manager?.getActiveJob()?.id ?? activeJobId;
145
146
  }
146
147
 
147
- /**
148
- * Get the currently active job status, if any.
149
- */
150
- export function getActiveJob(): JobStatus | null {
151
- if (!activeJobId) {
152
- return null;
148
+ export function getActiveJob(manager?: JobManager): JobStatus | null {
149
+ if (manager) {
150
+ const record = manager.getActiveJob();
151
+ return record ? fromResidentRecord(record) : null;
153
152
  }
154
- return jobs.get(activeJobId) ?? null;
153
+ return activeJobId ? (jobs.get(activeJobId) ?? null) : null;
155
154
  }
156
155
 
157
- /**
158
- * Update job progress (called from within job execution).
159
- *
160
- * @param jobId - Job ID to update
161
- * @param progress - Current progress info
162
- */
163
- export function updateJobProgress(jobId: string, progress: JobProgress): void {
164
- const job = jobs.get(jobId);
165
- if (job) {
166
- job.progress = progress;
156
+ export function updateJobProgress(
157
+ jobId: string,
158
+ progress: JobProgress,
159
+ manager?: JobManager
160
+ ): void {
161
+ if (manager) {
162
+ manager.updateJobProgress(jobId, progress);
163
+ return;
167
164
  }
165
+ const job = jobs.get(jobId);
166
+ if (job) job.progress = progress;
168
167
  }
169
168
 
170
- /**
171
- * Get all jobs (for debugging/monitoring).
172
- */
173
- export function getAllJobs(): JobStatus[] {
169
+ export function getAllJobs(manager?: JobManager): JobStatus[] {
170
+ if (manager) {
171
+ const listed = manager.listJobs(100);
172
+ return [...listed.active, ...listed.recent].map(fromResidentRecord);
173
+ }
174
174
  cleanupExpiredJobs();
175
175
  return Array.from(jobs.values());
176
176
  }
177
177
 
178
- /**
179
- * Clear all jobs (for testing).
180
- */
181
- export function clearAllJobs(): void {
178
+ export function clearAllJobs(manager?: JobManager): void {
179
+ if (manager) manager.clear();
182
180
  jobs.clear();
183
181
  activeJobId = null;
184
182
  }