@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
@@ -0,0 +1,490 @@
1
+ /** Single-process ownership boundary shared by serve and daemon surfaces. */
2
+
3
+ // node:path resolve/dirname/join have no Bun path utility equivalents.
4
+ import { dirname, join, resolve } from "node:path";
5
+
6
+ import type { Config } from "../config/types";
7
+ import type { WriteLockHandle } from "../core/file-lock";
8
+ import type { SyncResult } from "../ingestion";
9
+ import type { ModelManager } from "../llm/nodeLlamaCpp/lifecycle";
10
+ import type { ToolContext } from "../mcp/context";
11
+ import type { HttpMcpTransportStatus } from "../mcp/http-transport";
12
+ import type { DocumentEventBus } from "./doc-events";
13
+ import type { EmbedResult, EmbedScheduler } from "./embed-scheduler";
14
+ import type { ContextHolder } from "./routes/api";
15
+ import type { ResidentStatus } from "./status-model";
16
+ import type {
17
+ CollectionWatchCallbacks,
18
+ CollectionWatchService,
19
+ } from "./watch-service";
20
+
21
+ import { DEFAULT_INDEX_NAME, getIndexDbPath } from "../app/constants";
22
+ import {
23
+ canonicalizeIndexName,
24
+ INDEX_NAME_REQUIREMENTS,
25
+ isValidIndexName,
26
+ } from "../app/index-name";
27
+ import {
28
+ ensureDirectories,
29
+ formatConfigWarnings,
30
+ getConfigPaths,
31
+ isInitialized,
32
+ loadConfig,
33
+ } from "../config";
34
+ import { acquireWriteLock } from "../core/file-lock";
35
+ import { JobManager } from "../core/job-manager";
36
+ import { recordContentMutation } from "../core/mutation-generations";
37
+ import { defaultSyncService, withContentTypeRules } from "../ingestion";
38
+ import { getModelManager } from "../llm/nodeLlamaCpp/lifecycle";
39
+ import { getModelConfig } from "../llm/registry";
40
+ import { getActivePreset } from "../llm/registry";
41
+ import { createToolContext, Mutex } from "../mcp/context";
42
+ import { SqliteAdapter } from "../store/sqlite/adapter";
43
+ import {
44
+ createServerContext,
45
+ type CreateServerContextOptions,
46
+ disposeServerContext,
47
+ type ServerContext,
48
+ } from "./context";
49
+ import { createEmbedScheduler } from "./embed-scheduler";
50
+ import { AdmissionController, ReaderGate } from "./resident-admission";
51
+ import { ResidentBackgroundWork } from "./resident-background-work";
52
+ import { buildResidentStatusSnapshot } from "./resident-status";
53
+ import { CollectionWatchService as DefaultCollectionWatchService } from "./watch-service";
54
+
55
+ const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
56
+ const OWNER_LOCK_TIMEOUT_MS = 0;
57
+
58
+ export type ResidentMode = "serve" | "daemon";
59
+
60
+ export interface ResidentRuntimeOptions {
61
+ configPath?: string;
62
+ index?: string;
63
+ mode?: ResidentMode;
64
+ requireCollections?: boolean;
65
+ offline?: boolean;
66
+ eventBus?: DocumentEventBus | null;
67
+ watchCallbacks?: CollectionWatchCallbacks;
68
+ readerLimit?: number;
69
+ readerQueueLimit?: number;
70
+ shutdownDeadlineMs?: number;
71
+ shutdownAbortSettleMs?: number;
72
+ }
73
+
74
+ export interface ResidentGeneration {
75
+ content: number;
76
+ index: number;
77
+ }
78
+
79
+ export interface ResidentRequestHandle {
80
+ id: string;
81
+ signal: AbortSignal;
82
+ finish(): void;
83
+ }
84
+
85
+ export interface ResidentRuntime {
86
+ readonly mode: ResidentMode;
87
+ readonly store: SqliteAdapter;
88
+ readonly config: Config;
89
+ readonly actualConfigPath: string;
90
+ readonly ctxHolder: ContextHolder;
91
+ readonly scheduler: EmbedScheduler;
92
+ readonly eventBus: DocumentEventBus | null;
93
+ readonly watchService: CollectionWatchService;
94
+ readonly toolMutex: Mutex;
95
+ readonly readerGate: ReaderGate;
96
+ readonly jobManager: JobManager;
97
+ readonly modelManager: ModelManager;
98
+ readonly mcpContext: ToolContext;
99
+ readonly generations: ResidentGeneration;
100
+ readonly activeRequests: number;
101
+ readonly activeSessions: number;
102
+ readonly isShuttingDown: boolean;
103
+ getStatus(): ResidentStatus;
104
+ setListenerPort(port: number | null): void;
105
+ setTransportStatusProvider(
106
+ provider: (() => HttpMcpTransportStatus) | null
107
+ ): void;
108
+ admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
109
+ withModelLease<T>(operation: () => Promise<T>): Promise<T>;
110
+ markContentMutation(): void;
111
+ markIndexMutation(): void;
112
+ startBackgroundWork(
113
+ operation: (signal: AbortSignal) => Promise<void>
114
+ ): boolean;
115
+ openSession(): () => void;
116
+ syncAll(options?: {
117
+ gitPull?: boolean;
118
+ runUpdateCmd?: boolean;
119
+ triggerEmbed?: boolean;
120
+ }): Promise<{ syncResult: SyncResult; embedResult: EmbedResult | null }>;
121
+ dispose(): Promise<void>;
122
+ }
123
+
124
+ export type ResidentRuntimeResult =
125
+ | { success: true; runtime: ResidentRuntime }
126
+ | { success: false; error: string };
127
+
128
+ export type ResidentRuntimeDeps = {
129
+ isInitialized?: typeof isInitialized;
130
+ loadConfig?: typeof loadConfig;
131
+ getConfigPaths?: typeof getConfigPaths;
132
+ ensureDirectories?: typeof ensureDirectories;
133
+ acquireOwnerLock?: (
134
+ path: string,
135
+ timeoutMs: number
136
+ ) => Promise<WriteLockHandle | null>;
137
+ storeFactory?: () => SqliteAdapter;
138
+ createServerContext?: (
139
+ store: SqliteAdapter,
140
+ config: Config,
141
+ options?: CreateServerContextOptions
142
+ ) => Promise<ServerContext>;
143
+ disposeServerContext?: (ctx: ServerContext) => Promise<void>;
144
+ createEmbedScheduler?: typeof createEmbedScheduler;
145
+ syncAllService?: typeof defaultSyncService.syncAll;
146
+ watchServiceFactory?: (options: {
147
+ collections: Config["collections"];
148
+ store: SqliteAdapter;
149
+ scheduler: EmbedScheduler | null;
150
+ eventBus?: DocumentEventBus | null;
151
+ callbacks?: CollectionWatchCallbacks;
152
+ syncOptions?: Parameters<typeof withContentTypeRules>[0];
153
+ }) => CollectionWatchService;
154
+ modelManagerFactory?: (config: Config) => ModelManager;
155
+ };
156
+
157
+ export async function startResidentRuntime(
158
+ options: ResidentRuntimeOptions = {},
159
+ deps: ResidentRuntimeDeps = {}
160
+ ): Promise<ResidentRuntimeResult> {
161
+ if (options.index !== undefined && !isValidIndexName(options.index)) {
162
+ return {
163
+ success: false,
164
+ error: `Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`,
165
+ };
166
+ }
167
+
168
+ const initialized = await (deps.isInitialized ?? isInitialized)(
169
+ options.configPath
170
+ );
171
+ if (!initialized)
172
+ return { success: false, error: "GNO not initialized. Run: gno init" };
173
+
174
+ const configResult = await (deps.loadConfig ?? loadConfig)(
175
+ options.configPath
176
+ );
177
+ if (!configResult.ok)
178
+ return { success: false, error: configResult.error.message };
179
+ for (const warning of formatConfigWarnings(configResult.warnings))
180
+ console.warn(warning);
181
+ const initialConfig = configResult.value;
182
+ if (options.requireCollections && initialConfig.collections.length === 0) {
183
+ return {
184
+ success: false,
185
+ error: "No collections configured. Run: gno collection add <path>",
186
+ };
187
+ }
188
+
189
+ await (deps.ensureDirectories ?? ensureDirectories)();
190
+ const dbPath = getIndexDbPath(options.index);
191
+ const ownerLockPath = join(dirname(dbPath), ".resident-owner.lock");
192
+ const ownerLock = await (deps.acquireOwnerLock ?? acquireWriteLock)(
193
+ ownerLockPath,
194
+ OWNER_LOCK_TIMEOUT_MS
195
+ );
196
+ if (!ownerLock) {
197
+ return {
198
+ success: false,
199
+ error: `Resident runtime already active for index "${canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME)}". Stop the owning gno serve or gno daemon process and retry.`,
200
+ };
201
+ }
202
+
203
+ const store = deps.storeFactory?.() ?? new SqliteAdapter();
204
+ const paths = (deps.getConfigPaths ?? getConfigPaths)();
205
+ const actualConfigPath = resolve(options.configPath ?? paths.configFile);
206
+ store.setConfigPath(actualConfigPath);
207
+ const openResult = await store.open(dbPath, initialConfig.ftsTokenizer);
208
+ if (!openResult.ok) {
209
+ await ownerLock.release();
210
+ return { success: false, error: openResult.error.message };
211
+ }
212
+
213
+ const failStartup = async (error: string): Promise<ResidentRuntimeResult> => {
214
+ await Promise.allSettled([store.close(), ownerLock.release()]);
215
+ return { success: false, error };
216
+ };
217
+ const syncCollections = await store.syncCollections(
218
+ initialConfig.collections
219
+ );
220
+ if (!syncCollections.ok) return failStartup(syncCollections.error.message);
221
+ const syncContexts = await store.syncContexts(initialConfig.contexts ?? []);
222
+ if (!syncContexts.ok) return failStartup(syncContexts.error.message);
223
+
224
+ let ctx: ServerContext;
225
+ try {
226
+ ctx = await (deps.createServerContext ?? createServerContext)(
227
+ store,
228
+ initialConfig,
229
+ {
230
+ offline: options.offline ?? false,
231
+ indexName: options.index,
232
+ }
233
+ );
234
+ } catch (error) {
235
+ return failStartup(error instanceof Error ? error.message : String(error));
236
+ }
237
+
238
+ const ctxHolder: ContextHolder = {
239
+ current: ctx,
240
+ config: initialConfig,
241
+ scheduler: null,
242
+ eventBus: options.eventBus ?? null,
243
+ watchService: null,
244
+ };
245
+ const modelManager =
246
+ deps.modelManagerFactory?.(initialConfig) ??
247
+ getModelManager(getModelConfig(initialConfig));
248
+ const generations: ResidentGeneration = { content: 0, index: 0 };
249
+ ctxHolder.markContentMutation = () => {
250
+ generations.content += 1;
251
+ };
252
+ ctxHolder.markIndexMutation = () => {
253
+ generations.index += 1;
254
+ };
255
+ const scheduler = (deps.createEmbedScheduler ?? createEmbedScheduler)({
256
+ db: store.getRawDb(),
257
+ getEmbedPort: () => ctxHolder.current.embedPort,
258
+ getVectorIndex: () => ctxHolder.current.vectorIndex,
259
+ getModelUri: () => getActivePreset(ctxHolder.config).embed,
260
+ acquireModelLease: () => modelManager.acquireLease(),
261
+ onEmbedded: () => {
262
+ generations.index += 1;
263
+ },
264
+ });
265
+ ctxHolder.scheduler = scheduler;
266
+ ctxHolder.current.scheduler = scheduler;
267
+ ctxHolder.current.eventBus = options.eventBus ?? null;
268
+
269
+ const watchService = (
270
+ deps.watchServiceFactory ??
271
+ ((watchOptions) => new DefaultCollectionWatchService(watchOptions))
272
+ )({
273
+ collections: initialConfig.collections,
274
+ store,
275
+ scheduler,
276
+ eventBus: options.eventBus ?? null,
277
+ callbacks: {
278
+ ...options.watchCallbacks,
279
+ onSyncComplete: (event) => {
280
+ recordContentMutation(event.result, () => {
281
+ generations.content += 1;
282
+ });
283
+ options.watchCallbacks?.onSyncComplete?.(event);
284
+ },
285
+ },
286
+ syncOptions: withContentTypeRules({}, initialConfig),
287
+ });
288
+ watchService.start();
289
+ ctxHolder.watchService = watchService;
290
+ ctxHolder.current.watchService = watchService;
291
+
292
+ const toolMutex = new Mutex();
293
+ const serverInstanceId = crypto.randomUUID();
294
+ const writeLockPath = join(dirname(dbPath), ".mcp-write.lock");
295
+ const jobManager = new JobManager({
296
+ lockPath: writeLockPath,
297
+ serverInstanceId,
298
+ toolMutex,
299
+ });
300
+ ctxHolder.jobManager = jobManager;
301
+ const admission = new AdmissionController();
302
+ const readerGate = new ReaderGate(
303
+ options.readerLimit,
304
+ options.readerQueueLimit
305
+ );
306
+ const startedAt = Date.now();
307
+ let listenerPort: number | null = null;
308
+ let transportStatusProvider: (() => HttpMcpTransportStatus) | null = null;
309
+ let shutdownState: ResidentStatus["shutdown"]["state"] = "none";
310
+ let admissionState: ResidentStatus["admission"]["state"] = "accepting";
311
+ let disposed = false;
312
+ const backgroundWork = new ResidentBackgroundWork(
313
+ () => !disposed && admission.accepting
314
+ );
315
+
316
+ const mcpContext = createToolContext({
317
+ store,
318
+ getConfig: () => ctxHolder.config,
319
+ setConfig: (config) => {
320
+ ctxHolder.config = config;
321
+ ctxHolder.current = { ...ctxHolder.current, config };
322
+ ctxHolder.watchService?.updateCollections(
323
+ config.collections,
324
+ withContentTypeRules({}, config)
325
+ );
326
+ },
327
+ actualConfigPath,
328
+ indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
329
+ toolMutex,
330
+ jobManager,
331
+ serverInstanceId,
332
+ writeLockPath,
333
+ enableWrite: false,
334
+ isShuttingDown: () => disposed || !admission.accepting,
335
+ acquireModelLease: () => modelManager.acquireLease(),
336
+ markContentMutation: () => {
337
+ generations.content += 1;
338
+ },
339
+ markIndexMutation: () => {
340
+ generations.index += 1;
341
+ },
342
+ });
343
+
344
+ const runtime: ResidentRuntime = {
345
+ mode: options.mode ?? "serve",
346
+ store,
347
+ get config() {
348
+ return ctxHolder.config;
349
+ },
350
+ actualConfigPath,
351
+ ctxHolder,
352
+ scheduler,
353
+ eventBus: options.eventBus ?? null,
354
+ watchService,
355
+ toolMutex,
356
+ readerGate,
357
+ jobManager,
358
+ modelManager,
359
+ mcpContext,
360
+ generations,
361
+ get activeRequests() {
362
+ return admission.active;
363
+ },
364
+ get activeSessions() {
365
+ return transportStatusProvider?.().activeSessions ?? 0;
366
+ },
367
+ get isShuttingDown() {
368
+ return disposed || !admission.accepting;
369
+ },
370
+ admitRequest: (signal) => admission.admit(signal),
371
+ async withModelLease<T>(operation: () => Promise<T>): Promise<T> {
372
+ const lease = modelManager.acquireLease();
373
+ try {
374
+ return await operation();
375
+ } finally {
376
+ lease.release();
377
+ }
378
+ },
379
+ markContentMutation() {
380
+ generations.content += 1;
381
+ },
382
+ markIndexMutation() {
383
+ generations.index += 1;
384
+ },
385
+ startBackgroundWork(operation) {
386
+ return backgroundWork.start(operation);
387
+ },
388
+ openSession() {
389
+ return () => undefined;
390
+ },
391
+ getStatus() {
392
+ const transport = transportStatusProvider?.() ?? {
393
+ activeRequests: 0,
394
+ activeSessions: 0,
395
+ queuedRequests: 0,
396
+ maxConcurrentRequests: 0,
397
+ maxQueuedRequests: 0,
398
+ maxSessions: 0,
399
+ };
400
+ const jobs = jobManager.listJobs(100);
401
+ return buildResidentStatusSnapshot({
402
+ mode: options.mode ?? "serve",
403
+ startedAt,
404
+ listenerPort,
405
+ admission: {
406
+ state: admissionState,
407
+ activeRequests: admission.active,
408
+ },
409
+ shutdown: { state: shutdownState },
410
+ transport,
411
+ readers: {
412
+ active: readerGate.active,
413
+ queued: readerGate.queued,
414
+ limit: readerGate.limit,
415
+ maxQueued: readerGate.maxQueued,
416
+ },
417
+ models: modelManager.getLifecycleStats(),
418
+ jobs: {
419
+ active: jobs.active.length,
420
+ recent: jobs.recent.length,
421
+ failed: jobs.recent.filter((job) => job.status === "failed").length,
422
+ },
423
+ generations: { ...generations },
424
+ });
425
+ },
426
+ setListenerPort(port) {
427
+ listenerPort = port;
428
+ },
429
+ setTransportStatusProvider(provider) {
430
+ transportStatusProvider = provider;
431
+ },
432
+ async syncAll(syncOptions = {}) {
433
+ const config = ctxHolder.config;
434
+ const syncAllService = deps.syncAllService
435
+ ? (...args: Parameters<typeof defaultSyncService.syncAll>) =>
436
+ deps.syncAllService!(...args)
437
+ : defaultSyncService.syncAll.bind(defaultSyncService);
438
+ const syncResult = await syncAllService(
439
+ config.collections,
440
+ store,
441
+ withContentTypeRules(
442
+ {
443
+ gitPull: syncOptions.gitPull,
444
+ runUpdateCmd: syncOptions.runUpdateCmd,
445
+ },
446
+ config
447
+ )
448
+ );
449
+ recordContentMutation(syncResult, () => {
450
+ generations.content += 1;
451
+ });
452
+ const embedResult =
453
+ syncOptions.triggerEmbed === false
454
+ ? null
455
+ : await scheduler.triggerNow();
456
+ return { syncResult, embedResult };
457
+ },
458
+ async dispose() {
459
+ if (disposed) return;
460
+ disposed = true;
461
+ admissionState = "draining";
462
+ shutdownState = "graceful";
463
+ const deadlineReached = await admission.closeAndDrain(
464
+ options.shutdownDeadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS,
465
+ options.shutdownAbortSettleMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS
466
+ );
467
+ if (deadlineReached) shutdownState = "deadline";
468
+ await backgroundWork.cancelAndDrain();
469
+ await jobManager.shutdown().catch(() => undefined);
470
+ await Promise.allSettled([
471
+ Promise.resolve().then(() => watchService.dispose()),
472
+ Promise.resolve().then(() => options.eventBus?.close()),
473
+ ]);
474
+ await Promise.allSettled([scheduler.dispose()]);
475
+ await Promise.allSettled([
476
+ (deps.disposeServerContext ?? disposeServerContext)(ctxHolder.current),
477
+ ]);
478
+ await Promise.allSettled([modelManager.disposeAll()]);
479
+ await Promise.allSettled([store.close()]);
480
+ await Promise.allSettled([ownerLock.release()]);
481
+ admissionState = "closed";
482
+ transportStatusProvider = null;
483
+ listenerPort = null;
484
+ },
485
+ };
486
+ ctxHolder.startBackgroundWork = (operation) =>
487
+ runtime.startBackgroundWork(operation);
488
+ mcpContext.getResidentStatus = () => runtime.getStatus();
489
+ return { success: true, runtime };
490
+ }
@@ -0,0 +1,96 @@
1
+ /** Safe, transport-neutral resident lifecycle status projection. */
2
+
3
+ import type { ResidentStatus, RuntimeMode } from "./status-model";
4
+
5
+ const EMPTY_MODELS: ResidentStatus["models"] = {
6
+ activeLeases: 0,
7
+ leaseAcquisitions: 0,
8
+ leaseReleases: 0,
9
+ loadedModels: 0,
10
+ loadAttempts: 0,
11
+ loadSuccesses: 0,
12
+ loadFailures: 0,
13
+ inflightLoads: 0,
14
+ };
15
+
16
+ export function createStandaloneResidentStatus(
17
+ mode: Extract<RuntimeMode, "stdio" | "direct-cli">
18
+ ): ResidentStatus {
19
+ return {
20
+ schemaVersion: "1.0",
21
+ mode,
22
+ resident: false,
23
+ uptimeSeconds: null,
24
+ listenerPort: null,
25
+ admission: { state: "closed", activeRequests: 0 },
26
+ shutdown: { state: "none" },
27
+ transport: {
28
+ activeRequests: 0,
29
+ activeSessions: 0,
30
+ queuedRequests: 0,
31
+ maxConcurrentRequests: 0,
32
+ maxQueuedRequests: 0,
33
+ maxSessions: 0,
34
+ },
35
+ readers: { active: 0, queued: 0, limit: 0, maxQueued: 0 },
36
+ models: { ...EMPTY_MODELS },
37
+ jobs: { active: 0, recent: 0, failed: 0 },
38
+ generations: { content: 0, index: 0 },
39
+ };
40
+ }
41
+
42
+ export interface ResidentStatusSnapshotInput {
43
+ mode: Extract<RuntimeMode, "serve" | "daemon">;
44
+ startedAt: number;
45
+ listenerPort: number | null;
46
+ admission: ResidentStatus["admission"];
47
+ shutdown: ResidentStatus["shutdown"];
48
+ transport: ResidentStatus["transport"];
49
+ readers: ResidentStatus["readers"];
50
+ models: ResidentStatus["models"];
51
+ jobs: ResidentStatus["jobs"];
52
+ generations: ResidentStatus["generations"];
53
+ now?: number;
54
+ }
55
+
56
+ export function buildResidentStatusSnapshot(
57
+ input: ResidentStatusSnapshotInput
58
+ ): ResidentStatus {
59
+ return {
60
+ schemaVersion: "1.0",
61
+ mode: input.mode,
62
+ resident: true,
63
+ uptimeSeconds: Math.max(
64
+ 0,
65
+ Math.floor(((input.now ?? Date.now()) - input.startedAt) / 1000)
66
+ ),
67
+ listenerPort: input.listenerPort,
68
+ admission: { ...input.admission },
69
+ shutdown: { ...input.shutdown },
70
+ transport: { ...input.transport },
71
+ readers: { ...input.readers },
72
+ models: { ...input.models },
73
+ jobs: { ...input.jobs },
74
+ generations: { ...input.generations },
75
+ };
76
+ }
77
+
78
+ export function isResidentStatus(value: unknown): value is ResidentStatus {
79
+ if (typeof value !== "object" || value === null) return false;
80
+ const candidate = value as Partial<ResidentStatus>;
81
+ return (
82
+ candidate.schemaVersion === "1.0" &&
83
+ typeof candidate.mode === "string" &&
84
+ typeof candidate.resident === "boolean" &&
85
+ typeof candidate.admission === "object" &&
86
+ candidate.admission !== null &&
87
+ typeof candidate.transport === "object" &&
88
+ candidate.transport !== null &&
89
+ typeof candidate.models === "object" &&
90
+ candidate.models !== null &&
91
+ typeof candidate.jobs === "object" &&
92
+ candidate.jobs !== null &&
93
+ typeof candidate.generations === "object" &&
94
+ candidate.generations !== null
95
+ );
96
+ }