@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.
- package/README.md +9 -1
- package/assets/screenshots/claudecodeskill.jpg +0 -0
- package/assets/screenshots/cli.jpg +0 -0
- package/assets/screenshots/mcp.jpg +0 -0
- package/assets/screenshots/webui-ask-answer.jpg +0 -0
- package/assets/screenshots/webui-home.jpg +0 -0
- package/package.json +4 -4
- package/src/cli/commands/ask.ts +41 -3
- package/src/cli/commands/collection/add.ts +27 -66
- package/src/cli/commands/collection/remove.ts +20 -39
- package/src/cli/commands/embed.ts +75 -20
- package/src/cli/commands/models/index.ts +1 -1
- package/src/cli/commands/models/pull.ts +0 -17
- package/src/cli/commands/query.ts +41 -3
- package/src/cli/context.ts +10 -0
- package/src/cli/program.ts +2 -1
- package/src/cli/progress.ts +88 -0
- package/src/cli/run.ts +1 -0
- package/src/collection/add.ts +113 -0
- package/src/collection/index.ts +17 -0
- package/src/collection/remove.ts +65 -0
- package/src/collection/types.ts +70 -0
- package/src/llm/cache.ts +187 -37
- package/src/llm/errors.ts +27 -4
- package/src/llm/lockfile.ts +216 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +54 -12
- package/src/llm/policy.ts +84 -0
- package/src/mcp/tools/query.ts +20 -3
- package/src/mcp/tools/vsearch.ts +12 -1
- package/src/serve/config-sync.ts +139 -0
- package/src/serve/context.ts +36 -3
- package/src/serve/jobs.ts +172 -0
- package/src/serve/routes/api.ts +432 -0
- package/src/serve/security.ts +84 -0
- package/src/serve/server.ts +126 -15
|
@@ -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
|
+
}
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -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
|
}
|
package/src/mcp/tools/vsearch.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/serve/context.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|