@gmickel/gno 0.6.0 → 0.7.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,84 @@
1
+ /**
2
+ * Download policy resolution.
3
+ * Determines whether model downloads are allowed based on env/flags.
4
+ *
5
+ * @module src/llm/policy
6
+ */
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Types
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ export interface DownloadPolicy {
13
+ /** True if network is disabled (no HF API calls at all) */
14
+ offline: boolean;
15
+ /** True if auto-download is allowed (may still be blocked by offline) */
16
+ allowDownload: boolean;
17
+ }
18
+
19
+ export interface PolicyFlags {
20
+ /** --offline CLI flag */
21
+ offline?: boolean;
22
+ }
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Helpers
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Check if env var is set (non-empty and truthy).
30
+ * Treats "1", "true", "yes" as truthy. Empty string or "0" as falsy.
31
+ */
32
+ export function envIsSet(
33
+ env: Record<string, string | undefined>,
34
+ key: string
35
+ ): boolean {
36
+ const val = env[key];
37
+ if (val === undefined || val === '') {
38
+ return false;
39
+ }
40
+ const lower = val.toLowerCase();
41
+ return lower === '1' || lower === 'true' || lower === 'yes';
42
+ }
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Main
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Resolve download policy from environment and CLI flags.
50
+ *
51
+ * Precedence (first wins):
52
+ * 1. --offline flag → offline=true, allowDownload=false
53
+ * 2. HF_HUB_OFFLINE=1 → offline=true, allowDownload=false
54
+ * 3. GNO_OFFLINE=1 → offline=true, allowDownload=false
55
+ * 4. GNO_NO_AUTO_DOWNLOAD=1 → offline=false, allowDownload=false
56
+ * 5. Default → offline=false, allowDownload=true
57
+ */
58
+ export function resolveDownloadPolicy(
59
+ env: Record<string, string | undefined>,
60
+ flags: PolicyFlags
61
+ ): DownloadPolicy {
62
+ // 1. --offline flag takes highest precedence
63
+ if (flags.offline) {
64
+ return { offline: true, allowDownload: false };
65
+ }
66
+
67
+ // 2. HF_HUB_OFFLINE env var (standard HuggingFace offline mode)
68
+ if (envIsSet(env, 'HF_HUB_OFFLINE')) {
69
+ return { offline: true, allowDownload: false };
70
+ }
71
+
72
+ // 3. GNO_OFFLINE env var (GNO-specific offline mode)
73
+ if (envIsSet(env, 'GNO_OFFLINE')) {
74
+ return { offline: true, allowDownload: false };
75
+ }
76
+
77
+ // 4. GNO_NO_AUTO_DOWNLOAD env var (allow resolve but no download)
78
+ if (envIsSet(env, 'GNO_NO_AUTO_DOWNLOAD')) {
79
+ return { offline: false, allowDownload: false };
80
+ }
81
+
82
+ // 5. Default: allow downloads
83
+ return { offline: false, allowDownload: true };
84
+ }
@@ -6,7 +6,9 @@
6
6
 
7
7
  import { join as pathJoin } from 'node:path';
8
8
  import { parseUri } from '../../app/constants';
9
+ import { createNonTtyProgressRenderer } from '../../cli/progress';
9
10
  import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
11
+ import { resolveDownloadPolicy } from '../../llm/policy';
10
12
  import { getActivePreset } from '../../llm/registry';
11
13
  import type {
12
14
  EmbeddingPort,
@@ -128,6 +130,12 @@ export function handleQuery(
128
130
  const preset = getActivePreset(ctx.config);
129
131
  const llm = new LlmAdapter(ctx.config);
130
132
 
133
+ // Resolve download policy from env (MCP has no CLI flags)
134
+ const policy = resolveDownloadPolicy(process.env, {});
135
+
136
+ // Non-TTY progress for MCP (periodic lines to stderr, not \r)
137
+ const downloadProgress = createNonTtyProgressRenderer();
138
+
131
139
  let embedPort: EmbeddingPort | null = null;
132
140
  let genPort: GenerationPort | null = null;
133
141
  let rerankPort: RerankPort | null = null;
@@ -135,7 +143,10 @@ export function handleQuery(
135
143
 
136
144
  try {
137
145
  // Create embedding port (for vector search) - optional
138
- const embedResult = await llm.createEmbeddingPort(preset.embed);
146
+ const embedResult = await llm.createEmbeddingPort(preset.embed, {
147
+ policy,
148
+ onProgress: (progress) => downloadProgress('embed', progress),
149
+ });
139
150
  if (embedResult.ok) {
140
151
  embedPort = embedResult.value;
141
152
  }
@@ -164,7 +175,10 @@ export function handleQuery(
164
175
 
165
176
  // Create generation port (for expansion) - optional
166
177
  if (!noExpand) {
167
- const genResult = await llm.createGenerationPort(preset.gen);
178
+ const genResult = await llm.createGenerationPort(preset.gen, {
179
+ policy,
180
+ onProgress: (progress) => downloadProgress('gen', progress),
181
+ });
168
182
  if (genResult.ok) {
169
183
  genPort = genResult.value;
170
184
  }
@@ -172,7 +186,10 @@ export function handleQuery(
172
186
 
173
187
  // Create rerank port - optional
174
188
  if (!noRerank) {
175
- const rerankResult = await llm.createRerankPort(preset.rerank);
189
+ const rerankResult = await llm.createRerankPort(preset.rerank, {
190
+ policy,
191
+ onProgress: (progress) => downloadProgress('rerank', progress),
192
+ });
176
193
  if (rerankResult.ok) {
177
194
  rerankPort = rerankResult.value;
178
195
  }
@@ -6,7 +6,9 @@
6
6
 
7
7
  import { join as pathJoin } from 'node:path';
8
8
  import { parseUri } from '../../app/constants';
9
+ import { createNonTtyProgressRenderer } from '../../cli/progress';
9
10
  import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
11
+ import { resolveDownloadPolicy } from '../../llm/policy';
10
12
  import { getActivePreset } from '../../llm/registry';
11
13
  import { formatQueryForEmbedding } from '../../pipeline/contextual';
12
14
  import type { SearchResult, SearchResults } from '../../pipeline/types';
@@ -109,9 +111,18 @@ export function handleVsearch(
109
111
  const preset = getActivePreset(ctx.config);
110
112
  const modelUri = preset.embed;
111
113
 
114
+ // Resolve download policy from env (MCP has no CLI flags)
115
+ const policy = resolveDownloadPolicy(process.env, {});
116
+
117
+ // Non-TTY progress for MCP (periodic lines to stderr, not \r)
118
+ const downloadProgress = createNonTtyProgressRenderer();
119
+
112
120
  // Create LLM adapter for embeddings
113
121
  const llm = new LlmAdapter(ctx.config);
114
- const embedResult = await llm.createEmbeddingPort(modelUri);
122
+ const embedResult = await llm.createEmbeddingPort(modelUri, {
123
+ policy,
124
+ onProgress: (progress) => downloadProgress('embed', progress),
125
+ });
115
126
  if (!embedResult.ok) {
116
127
  throw new Error(
117
128
  `Failed to load embedding model: ${embedResult.error.message}. ` +
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Config synchronization utilities for the API server.
3
+ * Ensures YAML config, DB tables, and in-memory context stay in sync.
4
+ *
5
+ * @module src/serve/config-sync
6
+ */
7
+
8
+ import { loadConfig, saveConfig } from '../config';
9
+ import type { Config } from '../config/types';
10
+ import type { SqliteAdapter } from '../store/sqlite/adapter';
11
+ import type { ContextHolder } from './routes/api';
12
+
13
+ export interface ConfigSyncResult {
14
+ ok: true;
15
+ config: Config;
16
+ }
17
+
18
+ export interface ConfigSyncError {
19
+ ok: false;
20
+ error: string;
21
+ /** Error code - can be system codes or mutation-specific codes passed through */
22
+ code: string;
23
+ }
24
+
25
+ export type ApplyConfigResult = ConfigSyncResult | ConfigSyncError;
26
+
27
+ export type MutationResult =
28
+ | { ok: true; config: Config }
29
+ | { ok: false; error: string; code: string };
30
+
31
+ /**
32
+ * In-memory mutex for serializing config mutations.
33
+ * Prevents lost updates when multiple requests try to modify config concurrently.
34
+ */
35
+ let configMutex: Promise<void> = Promise.resolve();
36
+
37
+ /**
38
+ * Apply a config mutation atomically with serialization.
39
+ *
40
+ * Sequence:
41
+ * 1. Acquire mutex (serialize with other config mutations)
42
+ * 2. Load current config from YAML
43
+ * 3. Apply mutation function (receives fresh config)
44
+ * 4. Save updated config to YAML (source of truth)
45
+ * 5. Sync DB tables (collections, contexts)
46
+ * 6. Update in-memory context holder (both config and current.config)
47
+ *
48
+ * If DB sync fails after YAML write, the error is returned but YAML remains
49
+ * updated (it's the source of truth). Next server startup will re-sync.
50
+ *
51
+ * @param ctxHolder - Context holder with config and current ServerContext
52
+ * @param store - Database adapter for syncing tables
53
+ * @param mutate - Async mutation function that receives fresh-loaded config
54
+ * @param configPath - Optional config path override (must match server's config)
55
+ */
56
+ export async function applyConfigChange(
57
+ ctxHolder: ContextHolder,
58
+ store: SqliteAdapter,
59
+ mutate: (config: Config) => Promise<MutationResult> | MutationResult,
60
+ configPath?: string
61
+ ): Promise<ApplyConfigResult> {
62
+ // Serialize config mutations to prevent lost updates
63
+ const previousMutex = configMutex;
64
+ let resolveMutex: () => void = () => {
65
+ /* no-op until assigned */
66
+ };
67
+ configMutex = new Promise((resolve) => {
68
+ resolveMutex = resolve;
69
+ });
70
+
71
+ try {
72
+ await previousMutex;
73
+
74
+ // 1. Load current config from YAML
75
+ const loadResult = await loadConfig(configPath);
76
+ if (!loadResult.ok) {
77
+ return {
78
+ ok: false,
79
+ error: loadResult.error.message,
80
+ code: 'LOAD_ERROR',
81
+ };
82
+ }
83
+
84
+ // 2. Apply mutation to freshly-loaded config
85
+ const mutationResult = await mutate(loadResult.value);
86
+ if (!mutationResult.ok) {
87
+ return {
88
+ ok: false,
89
+ error: mutationResult.error,
90
+ code: mutationResult.code, // Pass through the original error code
91
+ };
92
+ }
93
+ const newConfig = mutationResult.config;
94
+
95
+ // 3. Save YAML atomically
96
+ const saveResult = await saveConfig(newConfig, configPath);
97
+ if (!saveResult.ok) {
98
+ return {
99
+ ok: false,
100
+ error: saveResult.error.message,
101
+ code: 'SAVE_ERROR',
102
+ };
103
+ }
104
+
105
+ // 4. Sync DB tables
106
+ const syncCollResult = await store.syncCollections(newConfig.collections);
107
+ if (!syncCollResult.ok) {
108
+ // YAML is saved, but DB sync failed - log warning
109
+ console.warn(
110
+ `Config saved but DB sync failed: ${syncCollResult.error.message}`
111
+ );
112
+ return {
113
+ ok: false,
114
+ error: `DB sync failed: ${syncCollResult.error.message}`,
115
+ code: 'SYNC_ERROR',
116
+ };
117
+ }
118
+
119
+ const syncCtxResult = await store.syncContexts(newConfig.contexts ?? []);
120
+ if (!syncCtxResult.ok) {
121
+ console.warn(
122
+ `Config saved but context sync failed: ${syncCtxResult.error.message}`
123
+ );
124
+ return {
125
+ ok: false,
126
+ error: `Context sync failed: ${syncCtxResult.error.message}`,
127
+ code: 'SYNC_ERROR',
128
+ };
129
+ }
130
+
131
+ // 5. Update both in-memory references
132
+ ctxHolder.config = newConfig;
133
+ ctxHolder.current = { ...ctxHolder.current, config: newConfig };
134
+
135
+ return { ok: true, config: newConfig };
136
+ } finally {
137
+ resolveMutex();
138
+ }
139
+ }
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { Config } from '../config/types';
9
+ import type { CreatePortOptions } from '../llm/nodeLlamaCpp/adapter';
9
10
  import { LlmAdapter } from '../llm/nodeLlamaCpp/adapter';
11
+ import { resolveDownloadPolicy } from '../llm/policy';
10
12
  import { getActivePreset } from '../llm/registry';
11
13
  import type {
12
14
  DownloadProgress,
@@ -87,8 +89,27 @@ export async function createServerContext(
87
89
  const preset = getActivePreset(config);
88
90
  const llm = new LlmAdapter(config);
89
91
 
92
+ // Resolve download policy from env (serve has no CLI flags)
93
+ const policy = resolveDownloadPolicy(process.env, {});
94
+
95
+ // Progress callback updates downloadState for WebUI polling
96
+ const createPortOptions = (type: ModelType): CreatePortOptions => ({
97
+ policy,
98
+ onProgress: (progress) => {
99
+ downloadState.active = true;
100
+ downloadState.currentType = type;
101
+ downloadState.progress = progress;
102
+ if (progress.percent >= 100) {
103
+ downloadState.completed.push(type);
104
+ }
105
+ },
106
+ });
107
+
90
108
  // Try to create embedding port
91
- const embedResult = await llm.createEmbeddingPort(preset.embed);
109
+ const embedResult = await llm.createEmbeddingPort(
110
+ preset.embed,
111
+ createPortOptions('embed')
112
+ );
92
113
  if (embedResult.ok) {
93
114
  embedPort = embedResult.value;
94
115
  const initResult = await embedPort.init();
@@ -108,18 +129,30 @@ export async function createServerContext(
108
129
  }
109
130
 
110
131
  // Try to create generation port
111
- const genResult = await llm.createGenerationPort(preset.gen);
132
+ const genResult = await llm.createGenerationPort(
133
+ preset.gen,
134
+ createPortOptions('gen')
135
+ );
112
136
  if (genResult.ok) {
113
137
  genPort = genResult.value;
114
138
  console.log('AI answer generation enabled');
115
139
  }
116
140
 
117
141
  // Try to create rerank port
118
- const rerankResult = await llm.createRerankPort(preset.rerank);
142
+ const rerankResult = await llm.createRerankPort(
143
+ preset.rerank,
144
+ createPortOptions('rerank')
145
+ );
119
146
  if (rerankResult.ok) {
120
147
  rerankPort = rerankResult.value;
121
148
  console.log('Reranking enabled');
122
149
  }
150
+
151
+ // Reset download state after initialization
152
+ if (downloadState.active) {
153
+ downloadState.active = false;
154
+ downloadState.currentType = null;
155
+ }
123
156
  } catch (e) {
124
157
  // Log but don't fail - models are optional
125
158
  console.log(
@@ -0,0 +1,172 @@
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
+ */
7
+
8
+ import type { SyncResult } from '../ingestion';
9
+
10
+ // Job expiration: 1 hour
11
+ const JOB_EXPIRATION_MS = 60 * 60 * 1000;
12
+
13
+ export type JobType = 'add' | 'sync' | 'embed';
14
+
15
+ export interface JobProgress {
16
+ current: number;
17
+ total: number;
18
+ currentFile?: string;
19
+ }
20
+
21
+ export interface JobStatus {
22
+ id: string;
23
+ type: JobType;
24
+ status: 'running' | 'completed' | 'failed';
25
+ createdAt: number;
26
+ progress?: JobProgress;
27
+ result?: SyncResult;
28
+ error?: string;
29
+ }
30
+
31
+ export interface StartJobSuccess {
32
+ ok: true;
33
+ jobId: string;
34
+ }
35
+
36
+ export interface StartJobError {
37
+ ok: false;
38
+ error: string;
39
+ status: 409;
40
+ }
41
+
42
+ export type StartJobResult = StartJobSuccess | StartJobError;
43
+
44
+ // Global state - only one job can run at a time
45
+ let activeJobId: string | null = null;
46
+ const jobs = new Map<string, JobStatus>();
47
+
48
+ /**
49
+ * Clean up expired jobs to prevent memory leaks.
50
+ * Called on every job access.
51
+ * Only expires completed/failed jobs - running jobs are never expired.
52
+ */
53
+ function cleanupExpiredJobs(now = Date.now()): void {
54
+ for (const [id, job] of jobs) {
55
+ // Never expire running jobs - only completed/failed
56
+ if (job.status === 'running') {
57
+ continue;
58
+ }
59
+ if (now - job.createdAt > JOB_EXPIRATION_MS) {
60
+ jobs.delete(id);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Start a background job.
67
+ * Returns immediately with jobId; caller polls /api/jobs/:id for status.
68
+ * Use updateJobProgress() to update progress during execution.
69
+ *
70
+ * @param type - Job type identifier
71
+ * @param fn - Async function to run in background
72
+ * @returns Job ID on success, or 409 error if job already running
73
+ */
74
+ export function startJob(
75
+ type: JobType,
76
+ fn: () => Promise<SyncResult>
77
+ ): StartJobResult {
78
+ // Cleanup expired jobs first
79
+ cleanupExpiredJobs();
80
+
81
+ // Check if a job is already running
82
+ if (activeJobId) {
83
+ return {
84
+ ok: false,
85
+ error: `Job ${activeJobId} already running`,
86
+ status: 409,
87
+ };
88
+ }
89
+
90
+ const jobId = crypto.randomUUID();
91
+ activeJobId = jobId;
92
+
93
+ const jobStatus: JobStatus = {
94
+ id: jobId,
95
+ type,
96
+ status: 'running',
97
+ createdAt: Date.now(),
98
+ };
99
+ jobs.set(jobId, jobStatus);
100
+
101
+ // Run in background (don't await)
102
+ // Wrap with Promise.resolve().then() to catch sync throws from fn()
103
+ // Without this, a sync throw would leave activeJobId set forever (deadlock)
104
+ Promise.resolve()
105
+ .then(fn)
106
+ .then((result) => {
107
+ const job = jobs.get(jobId);
108
+ if (job) {
109
+ job.status = 'completed';
110
+ job.result = result;
111
+ }
112
+ })
113
+ .catch((e) => {
114
+ const job = jobs.get(jobId);
115
+ if (job) {
116
+ job.status = 'failed';
117
+ job.error = e instanceof Error ? e.message : String(e);
118
+ }
119
+ })
120
+ .finally(() => {
121
+ activeJobId = null;
122
+ });
123
+
124
+ return { ok: true, jobId };
125
+ }
126
+
127
+ /**
128
+ * Get current status of a job.
129
+ *
130
+ * @param jobId - Job ID from startJob
131
+ * @returns Job status or undefined if not found
132
+ */
133
+ export function getJobStatus(jobId: string): JobStatus | undefined {
134
+ cleanupExpiredJobs();
135
+ return jobs.get(jobId);
136
+ }
137
+
138
+ /**
139
+ * Get the currently active job ID, if any.
140
+ */
141
+ export function getActiveJobId(): string | null {
142
+ return activeJobId;
143
+ }
144
+
145
+ /**
146
+ * Update job progress (called from within job execution).
147
+ *
148
+ * @param jobId - Job ID to update
149
+ * @param progress - Current progress info
150
+ */
151
+ export function updateJobProgress(jobId: string, progress: JobProgress): void {
152
+ const job = jobs.get(jobId);
153
+ if (job) {
154
+ job.progress = progress;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get all jobs (for debugging/monitoring).
160
+ */
161
+ export function getAllJobs(): JobStatus[] {
162
+ cleanupExpiredJobs();
163
+ return Array.from(jobs.values());
164
+ }
165
+
166
+ /**
167
+ * Clear all jobs (for testing).
168
+ */
169
+ export function clearAllJobs(): void {
170
+ jobs.clear();
171
+ activeJobId = null;
172
+ }