@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
@@ -16,6 +16,7 @@ import type {
16
16
  Config,
17
17
  ModelPreset,
18
18
  } from "../../config/types";
19
+ import type { JobManager } from "../../core/job-manager";
19
20
  import type {
20
21
  AskResult,
21
22
  Citation,
@@ -32,6 +33,7 @@ import type {
32
33
  import type { DocumentEventBus } from "../doc-events";
33
34
  import type { EmbedScheduler } from "../embed-scheduler";
34
35
  import type { StartJobError } from "../jobs";
36
+ import type { ResidentStatus } from "../status-model";
35
37
  import type { CollectionWatchService } from "../watch-service";
36
38
 
37
39
  import { modelsPull } from "../../cli/commands/models/pull";
@@ -76,6 +78,11 @@ import {
76
78
  planMoveRefactor,
77
79
  planRenameRefactor,
78
80
  } from "../../core/file-refactors";
81
+ import {
82
+ hasContentMutation,
83
+ recordContentMutation,
84
+ recordIndexMutation,
85
+ } from "../../core/mutation-generations";
79
86
  import {
80
87
  resolveNoteCreatePlan,
81
88
  sanitizeNoteFilename,
@@ -147,6 +154,26 @@ export interface ContextHolder {
147
154
  scheduler: EmbedScheduler | null;
148
155
  eventBus: DocumentEventBus | null;
149
156
  watchService: CollectionWatchService | null;
157
+ jobManager?: JobManager;
158
+ markContentMutation?: () => void;
159
+ markIndexMutation?: () => void;
160
+ startBackgroundWork?: (
161
+ operation: (signal: AbortSignal) => Promise<void>
162
+ ) => boolean;
163
+ }
164
+
165
+ async function syncResidentCollection(
166
+ ctxHolder: ContextHolder,
167
+ collection: Collection,
168
+ store: SqliteAdapter,
169
+ options: Parameters<typeof defaultSyncService.syncCollection>[2],
170
+ syncCollection: typeof defaultSyncService.syncCollection = defaultSyncService.syncCollection.bind(
171
+ defaultSyncService
172
+ )
173
+ ): ReturnType<typeof defaultSyncService.syncCollection> {
174
+ const result = await syncCollection(collection, store, options);
175
+ recordContentMutation(result, ctxHolder.markContentMutation);
176
+ return result;
150
177
  }
151
178
 
152
179
  // ─────────────────────────────────────────────────────────────────────────────
@@ -815,6 +842,12 @@ export async function handleStatus(
815
842
  }
816
843
  }
817
844
 
845
+ export function handleResidentStatus(
846
+ getStatus: () => ResidentStatus
847
+ ): Response {
848
+ return jsonResponse(getStatus());
849
+ }
850
+
818
851
  /**
819
852
  * GET /api/collections
820
853
  * Returns list of collections.
@@ -990,33 +1023,38 @@ export async function handleCreateCollection(
990
1023
  if (!collection) {
991
1024
  return errorResponse("RUNTIME", "Collection not found after add", 500);
992
1025
  }
993
- const jobResult = startJob("add", async (): Promise<SyncResult> => {
994
- const result = await defaultSyncService.syncCollection(
995
- collection,
996
- store,
997
- withContentTypeRules(
998
- {
999
- gitPull: body.gitPull,
1000
- runUpdateCmd: true,
1001
- },
1002
- syncResult.config
1003
- )
1004
- );
1005
- if (result.filesAdded > 0 || result.filesUpdated > 0) {
1006
- if (ctxHolder.scheduler) {
1007
- await ctxHolder.scheduler.triggerNow();
1026
+ const jobResult = await startJob(
1027
+ "add",
1028
+ async (): Promise<SyncResult> => {
1029
+ const result = await syncResidentCollection(
1030
+ ctxHolder,
1031
+ collection,
1032
+ store,
1033
+ withContentTypeRules(
1034
+ {
1035
+ gitPull: body.gitPull,
1036
+ runUpdateCmd: true,
1037
+ },
1038
+ syncResult.config
1039
+ )
1040
+ );
1041
+ if (result.filesAdded > 0 || result.filesUpdated > 0) {
1042
+ if (ctxHolder.scheduler) {
1043
+ await ctxHolder.scheduler.triggerNow();
1044
+ }
1008
1045
  }
1009
- }
1010
- return {
1011
- collections: [result],
1012
- totalDurationMs: result.durationMs,
1013
- totalFilesProcessed: result.filesProcessed,
1014
- totalFilesAdded: result.filesAdded,
1015
- totalFilesUpdated: result.filesUpdated,
1016
- totalFilesErrored: result.filesErrored,
1017
- totalFilesSkipped: result.filesSkipped,
1018
- };
1019
- });
1046
+ return {
1047
+ collections: [result],
1048
+ totalDurationMs: result.durationMs,
1049
+ totalFilesProcessed: result.filesProcessed,
1050
+ totalFilesAdded: result.filesAdded,
1051
+ totalFilesUpdated: result.filesUpdated,
1052
+ totalFilesErrored: result.filesErrored,
1053
+ totalFilesSkipped: result.filesSkipped,
1054
+ };
1055
+ },
1056
+ ctxHolder.jobManager
1057
+ );
1020
1058
 
1021
1059
  if (!jobResult.ok) {
1022
1060
  return jobConflictResponse(jobResult);
@@ -1100,7 +1138,8 @@ export async function handleDeleteCollection(
1100
1138
  const status = statusMap[syncResult.code] ?? 500;
1101
1139
  return errorResponse(syncResult.code, syncResult.error, status);
1102
1140
  }
1103
-
1141
+ ctxHolder.markContentMutation?.();
1142
+ ctxHolder.markIndexMutation?.();
1104
1143
  return jsonResponse({
1105
1144
  success: true,
1106
1145
  collection: name,
@@ -1231,6 +1270,7 @@ export async function handleClearCollectionEmbeddings(
1231
1270
  const status = result.error.code === "INVALID_INPUT" ? 400 : 500;
1232
1271
  return errorResponse(result.error.code, result.error.message, status);
1233
1272
  }
1273
+ recordIndexMutation(result.value.deletedVectors, ctxHolder.markIndexMutation);
1234
1274
 
1235
1275
  return jsonResponse({
1236
1276
  success: true,
@@ -1292,25 +1332,30 @@ export async function handleSync(
1292
1332
  }
1293
1333
 
1294
1334
  // Start background sync job
1295
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
1296
- const result = await defaultSyncService.syncAll(
1297
- collections,
1298
- store,
1299
- withContentTypeRules(
1300
- {
1301
- gitPull: body.gitPull,
1302
- runUpdateCmd: true,
1303
- },
1304
- ctxHolder.config
1305
- )
1306
- );
1307
- if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
1308
- if (ctxHolder.scheduler) {
1309
- await ctxHolder.scheduler.triggerNow();
1335
+ const jobResult = await startJob(
1336
+ "sync",
1337
+ async (): Promise<SyncResult> => {
1338
+ const result = await defaultSyncService.syncAll(
1339
+ collections,
1340
+ store,
1341
+ withContentTypeRules(
1342
+ {
1343
+ gitPull: body.gitPull,
1344
+ runUpdateCmd: true,
1345
+ },
1346
+ ctxHolder.config
1347
+ )
1348
+ );
1349
+ recordContentMutation(result, ctxHolder.markContentMutation);
1350
+ if (hasContentMutation(result)) {
1351
+ if (ctxHolder.scheduler) {
1352
+ await ctxHolder.scheduler.triggerNow();
1353
+ }
1310
1354
  }
1311
- }
1312
- return result;
1313
- });
1355
+ return result;
1356
+ },
1357
+ ctxHolder.jobManager
1358
+ );
1314
1359
 
1315
1360
  if (!jobResult.ok) {
1316
1361
  return jobConflictResponse(jobResult);
@@ -1797,6 +1842,7 @@ export async function handleTags(
1797
1842
  * Deactivate a document (soft delete - does not remove file from disk).
1798
1843
  */
1799
1844
  export async function handleDeactivateDoc(
1845
+ ctxHolder: ContextHolder,
1800
1846
  store: SqliteAdapter,
1801
1847
  docId: string,
1802
1848
  req?: Request
@@ -1821,6 +1867,10 @@ export async function handleDeactivateDoc(
1821
1867
  if (!result.ok) {
1822
1868
  return errorResponse("RUNTIME", result.error.message, 500);
1823
1869
  }
1870
+ if (result.value > 0) {
1871
+ ctxHolder.markContentMutation?.();
1872
+ ctxHolder.markIndexMutation?.();
1873
+ }
1824
1874
 
1825
1875
  return jsonResponse({
1826
1876
  success: true,
@@ -1929,10 +1979,12 @@ export async function handleRenameDoc(
1929
1979
  const nextUri = `gno://${collection.name}/${nextRelPath}`;
1930
1980
  let warning: string | undefined;
1931
1981
  try {
1932
- await syncCollection(
1982
+ await syncResidentCollection(
1983
+ ctxHolder,
1933
1984
  collection,
1934
1985
  store,
1935
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
1986
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
1987
+ syncCollection
1936
1988
  );
1937
1989
  } catch {
1938
1990
  warning =
@@ -2151,10 +2203,12 @@ export async function handleTrashDoc(
2151
2203
  }
2152
2204
  let warning: string | undefined;
2153
2205
  try {
2154
- await syncCollection(
2206
+ await syncResidentCollection(
2207
+ ctxHolder,
2155
2208
  collection,
2156
2209
  store,
2157
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2210
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
2211
+ syncCollection
2158
2212
  );
2159
2213
  } catch {
2160
2214
  warning =
@@ -2274,7 +2328,8 @@ export async function handleMoveDoc(
2274
2328
  await renameFilePath(fullPath, nextFullPath);
2275
2329
  let warning: string | undefined;
2276
2330
  try {
2277
- await defaultSyncService.syncCollection(
2331
+ await syncResidentCollection(
2332
+ ctxHolder,
2278
2333
  collection,
2279
2334
  store,
2280
2335
  withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
@@ -2397,7 +2452,8 @@ export async function handleDuplicateDoc(
2397
2452
  await copyFilePath(fullPath, nextFullPath);
2398
2453
  let warning: string | undefined;
2399
2454
  try {
2400
- await defaultSyncService.syncCollection(
2455
+ await syncResidentCollection(
2456
+ ctxHolder,
2401
2457
  collection,
2402
2458
  store,
2403
2459
  withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
@@ -2546,7 +2602,10 @@ export async function handleUpdateDoc(
2546
2602
  ctxHolder: ContextHolder,
2547
2603
  store: SqliteAdapter,
2548
2604
  docId: string,
2549
- req: Request
2605
+ req: Request,
2606
+ deps?: {
2607
+ syncCollection?: typeof defaultSyncService.syncCollection;
2608
+ }
2550
2609
  ): Promise<Response> {
2551
2610
  let body: UpdateDocRequestBody;
2552
2611
  try {
@@ -2726,32 +2785,38 @@ export async function handleUpdateDoc(
2726
2785
  // Note: embedding handled separately by embed-scheduler (not inline)
2727
2786
  let jobId: string | null = null;
2728
2787
  if (contentToWrite !== undefined) {
2729
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2730
- const result = await defaultSyncService.syncCollection(
2731
- collection,
2732
- store,
2733
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2734
- );
2735
- // Notify scheduler after sync completes
2736
- ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
2737
- ctxHolder.eventBus?.emit({
2738
- type: "document-changed",
2739
- uri: doc.uri,
2740
- collection: doc.collection,
2741
- relPath: doc.relPath,
2742
- origin: "save",
2743
- changedAt: new Date().toISOString(),
2744
- });
2745
- return {
2746
- collections: [result],
2747
- totalDurationMs: result.durationMs,
2748
- totalFilesProcessed: result.filesProcessed,
2749
- totalFilesAdded: result.filesAdded,
2750
- totalFilesUpdated: result.filesUpdated,
2751
- totalFilesErrored: result.filesErrored,
2752
- totalFilesSkipped: result.filesSkipped,
2753
- };
2754
- });
2788
+ const jobResult = await startJob(
2789
+ "sync",
2790
+ async (): Promise<SyncResult> => {
2791
+ const result = await syncResidentCollection(
2792
+ ctxHolder,
2793
+ collection,
2794
+ store,
2795
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
2796
+ deps?.syncCollection
2797
+ );
2798
+ // Notify scheduler after sync completes
2799
+ ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
2800
+ ctxHolder.eventBus?.emit({
2801
+ type: "document-changed",
2802
+ uri: doc.uri,
2803
+ collection: doc.collection,
2804
+ relPath: doc.relPath,
2805
+ origin: "save",
2806
+ changedAt: new Date().toISOString(),
2807
+ });
2808
+ return {
2809
+ collections: [result],
2810
+ totalDurationMs: result.durationMs,
2811
+ totalFilesProcessed: result.filesProcessed,
2812
+ totalFilesAdded: result.filesAdded,
2813
+ totalFilesUpdated: result.filesUpdated,
2814
+ totalFilesErrored: result.filesErrored,
2815
+ totalFilesSkipped: result.filesSkipped,
2816
+ };
2817
+ },
2818
+ ctxHolder.jobManager
2819
+ );
2755
2820
  jobId = jobResult.ok ? jobResult.jobId : null;
2756
2821
  }
2757
2822
 
@@ -2906,7 +2971,10 @@ export async function handleCreateEditableCopy(
2906
2971
  export async function handleCreateCapture(
2907
2972
  ctxHolder: ContextHolder,
2908
2973
  store: SqliteAdapter,
2909
- req: Request
2974
+ req: Request,
2975
+ deps?: {
2976
+ syncCollection?: typeof defaultSyncService.syncCollection;
2977
+ }
2910
2978
  ): Promise<Response> {
2911
2979
  let body: CreateCaptureRequestBody;
2912
2980
  try {
@@ -2994,31 +3062,37 @@ export async function handleCreateCapture(
2994
3062
  await writeCapturePlanFile(plan, fullPath);
2995
3063
 
2996
3064
  const gnoUri = `gno://${collection.name}/${plan.relPath}`;
2997
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2998
- const result = await defaultSyncService.syncCollection(
2999
- collection,
3000
- store,
3001
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
3002
- );
3003
- ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3004
- ctxHolder.eventBus?.emit({
3005
- type: "document-changed",
3006
- uri: gnoUri,
3007
- collection: collection.name,
3008
- relPath: plan.relPath,
3009
- origin: "create",
3010
- changedAt: new Date().toISOString(),
3011
- });
3012
- return {
3013
- collections: [result],
3014
- totalDurationMs: result.durationMs,
3015
- totalFilesProcessed: result.filesProcessed,
3016
- totalFilesAdded: result.filesAdded,
3017
- totalFilesUpdated: result.filesUpdated,
3018
- totalFilesErrored: result.filesErrored,
3019
- totalFilesSkipped: result.filesSkipped,
3020
- };
3021
- });
3065
+ const jobResult = await startJob(
3066
+ "sync",
3067
+ async (): Promise<SyncResult> => {
3068
+ const result = await syncResidentCollection(
3069
+ ctxHolder,
3070
+ collection,
3071
+ store,
3072
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3073
+ deps?.syncCollection
3074
+ );
3075
+ ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3076
+ ctxHolder.eventBus?.emit({
3077
+ type: "document-changed",
3078
+ uri: gnoUri,
3079
+ collection: collection.name,
3080
+ relPath: plan.relPath,
3081
+ origin: "create",
3082
+ changedAt: new Date().toISOString(),
3083
+ });
3084
+ return {
3085
+ collections: [result],
3086
+ totalDurationMs: result.durationMs,
3087
+ totalFilesProcessed: result.filesProcessed,
3088
+ totalFilesAdded: result.filesAdded,
3089
+ totalFilesUpdated: result.filesUpdated,
3090
+ totalFilesErrored: result.filesErrored,
3091
+ totalFilesSkipped: result.filesSkipped,
3092
+ };
3093
+ },
3094
+ ctxHolder.jobManager
3095
+ );
3022
3096
 
3023
3097
  return jsonResponse(
3024
3098
  buildCaptureReceipt({
@@ -3058,7 +3132,10 @@ export async function handleCreateCapture(
3058
3132
  export async function handleCreateDoc(
3059
3133
  ctxHolder: ContextHolder,
3060
3134
  store: SqliteAdapter,
3061
- req: Request
3135
+ req: Request,
3136
+ deps?: {
3137
+ syncCollection?: typeof defaultSyncService.syncCollection;
3138
+ }
3062
3139
  ): Promise<Response> {
3063
3140
  let body: CreateDocRequestBody;
3064
3141
  try {
@@ -3230,34 +3307,40 @@ export async function handleCreateDoc(
3230
3307
 
3231
3308
  // Run sync via job system (non-blocking)
3232
3309
  // Note: embedding handled separately by embed-scheduler (not inline)
3233
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
3234
- const result = await defaultSyncService.syncCollection(
3235
- collection,
3236
- store,
3237
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
3238
- );
3239
- // Notify scheduler after sync completes (use gnoUri as docid placeholder)
3240
- // The sync will create a proper docid, but we don't have it here yet
3241
- // Using normalizedRelPath as identifier since docid is generated during sync
3242
- ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
3243
- ctxHolder.eventBus?.emit({
3244
- type: "document-changed",
3245
- uri: gnoUri,
3246
- collection: collection.name,
3247
- relPath: normalizedRelPath,
3248
- origin: "create",
3249
- changedAt: new Date().toISOString(),
3250
- });
3251
- return {
3252
- collections: [result],
3253
- totalDurationMs: result.durationMs,
3254
- totalFilesProcessed: result.filesProcessed,
3255
- totalFilesAdded: result.filesAdded,
3256
- totalFilesUpdated: result.filesUpdated,
3257
- totalFilesErrored: result.filesErrored,
3258
- totalFilesSkipped: result.filesSkipped,
3259
- };
3260
- });
3310
+ const jobResult = await startJob(
3311
+ "sync",
3312
+ async (): Promise<SyncResult> => {
3313
+ const result = await syncResidentCollection(
3314
+ ctxHolder,
3315
+ collection,
3316
+ store,
3317
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3318
+ deps?.syncCollection
3319
+ );
3320
+ // Notify scheduler after sync completes (use gnoUri as docid placeholder)
3321
+ // The sync will create a proper docid, but we don't have it here yet
3322
+ // Using normalizedRelPath as identifier since docid is generated during sync
3323
+ ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
3324
+ ctxHolder.eventBus?.emit({
3325
+ type: "document-changed",
3326
+ uri: gnoUri,
3327
+ collection: collection.name,
3328
+ relPath: normalizedRelPath,
3329
+ origin: "create",
3330
+ changedAt: new Date().toISOString(),
3331
+ });
3332
+ return {
3333
+ collections: [result],
3334
+ totalDurationMs: result.durationMs,
3335
+ totalFilesProcessed: result.filesProcessed,
3336
+ totalFilesAdded: result.filesAdded,
3337
+ totalFilesUpdated: result.filesUpdated,
3338
+ totalFilesErrored: result.filesErrored,
3339
+ totalFilesSkipped: result.filesSkipped,
3340
+ };
3341
+ },
3342
+ ctxHolder.jobManager
3343
+ );
3261
3344
 
3262
3345
  return jsonResponse(
3263
3346
  {
@@ -4141,7 +4224,13 @@ export function handleModelStatus(): Response {
4141
4224
  * Start downloading models for current preset.
4142
4225
  * Returns immediately; poll /api/models/status for progress.
4143
4226
  */
4144
- export function handleModelPull(ctxHolder: ContextHolder): Response {
4227
+ export function handleModelPull(
4228
+ ctxHolder: ContextHolder,
4229
+ deps?: {
4230
+ modelsPullFn?: typeof modelsPull;
4231
+ reloadServerContextFn?: typeof reloadServerContext;
4232
+ }
4233
+ ): Response {
4145
4234
  // Don't start if already downloading
4146
4235
  if (downloadState.active) {
4147
4236
  return errorResponse("CONFLICT", "Download already in progress", 409);
@@ -4152,17 +4241,20 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4152
4241
  downloadState.active = true;
4153
4242
  downloadState.startedAt = Date.now();
4154
4243
 
4155
- // Run download in background (don't await)
4156
- // Pass current config so it uses the active preset from UI
4157
- modelsPull({
4158
- config: ctxHolder.config,
4159
- all: true,
4160
- onProgress: (type, progress) => {
4161
- downloadState.currentType = type;
4162
- downloadState.progress = progress;
4163
- },
4164
- })
4165
- .then(async (result) => {
4244
+ const operation = async (signal: AbortSignal): Promise<void> => {
4245
+ try {
4246
+ const result = await (deps?.modelsPullFn ?? modelsPull)({
4247
+ config: ctxHolder.config,
4248
+ all: true,
4249
+ signal,
4250
+ onProgress: (type, progress) => {
4251
+ if (signal.aborted) return;
4252
+ downloadState.currentType = type;
4253
+ downloadState.progress = progress;
4254
+ },
4255
+ });
4256
+ if (signal.aborted) return;
4257
+
4166
4258
  // Track results
4167
4259
  for (const r of result.results) {
4168
4260
  if (r.ok) {
@@ -4180,35 +4272,39 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4180
4272
  // Reload context to pick up new models
4181
4273
  console.log("Models downloaded, reloading context...");
4182
4274
  try {
4183
- ctxHolder.current = await reloadServerContext(
4184
- ctxHolder.current,
4185
- ctxHolder.config
4186
- );
4275
+ ctxHolder.current = await (
4276
+ deps?.reloadServerContextFn ?? reloadServerContext
4277
+ )(ctxHolder.current, ctxHolder.config);
4278
+ if (signal.aborted) return;
4187
4279
  console.log("Context reloaded");
4188
4280
  if (ctxHolder.scheduler) {
4189
- void ctxHolder.scheduler.triggerNow().catch((error) => {
4190
- console.error(
4191
- "Failed to trigger embedding after model download:",
4192
- error
4193
- );
4194
- });
4281
+ await ctxHolder.scheduler.triggerNow();
4195
4282
  }
4196
4283
  } catch (e) {
4197
4284
  console.error("Failed to reload context:", e);
4198
4285
  }
4199
-
4200
- downloadState.active = false;
4201
- downloadState.currentType = null;
4202
- downloadState.progress = null;
4203
- })
4204
- .catch((e) => {
4286
+ } catch (e) {
4205
4287
  console.error("Model download failed:", e);
4206
- downloadState.active = false;
4207
4288
  downloadState.failed.push({
4208
4289
  type: downloadState.currentType ?? "embed",
4209
4290
  error: e instanceof Error ? e.message : String(e),
4210
4291
  });
4211
- });
4292
+ } finally {
4293
+ downloadState.active = false;
4294
+ downloadState.currentType = null;
4295
+ downloadState.progress = null;
4296
+ }
4297
+ };
4298
+
4299
+ const started = ctxHolder.startBackgroundWork?.(operation) ?? false;
4300
+ if (!started) {
4301
+ resetDownloadState();
4302
+ return errorResponse(
4303
+ "UNAVAILABLE",
4304
+ "Resident runtime is shutting down",
4305
+ 503
4306
+ );
4307
+ }
4212
4308
 
4213
4309
  return jsonResponse({
4214
4310
  started: true,
@@ -4224,8 +4320,8 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4224
4320
  * GET /api/jobs/:id
4225
4321
  * Poll job status for async operations.
4226
4322
  */
4227
- export function handleJob(jobId: string): Response {
4228
- const status = getJobStatus(jobId);
4323
+ export function handleJob(jobId: string, jobManager?: JobManager): Response {
4324
+ const status = getJobStatus(jobId, jobManager);
4229
4325
  if (!status) {
4230
4326
  return errorResponse("NOT_FOUND", "Job not found or expired", 404);
4231
4327
  }
@@ -4236,9 +4332,9 @@ export function handleJob(jobId: string): Response {
4236
4332
  * GET /api/jobs/active
4237
4333
  * Returns the current active job, or null when idle.
4238
4334
  */
4239
- export function handleActiveJob(): Response {
4335
+ export function handleActiveJob(jobManager?: JobManager): Response {
4240
4336
  return jsonResponse({
4241
- activeJob: getActiveJob(),
4337
+ activeJob: getActiveJob(jobManager),
4242
4338
  });
4243
4339
  }
4244
4340