@gmickel/gno 0.6.0 → 0.6.1
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 +1 -1
- package/src/cli/commands/ask.ts +41 -3
- package/src/cli/commands/embed.ts +29 -2
- 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/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/context.ts +36 -3
package/src/llm/cache.ts
CHANGED
|
@@ -5,18 +5,24 @@
|
|
|
5
5
|
* @module src/llm/cache
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// node:crypto: createHash for safe lock filenames
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
9
11
|
// node:path: join for path construction, isAbsolute for cross-platform path detection
|
|
10
12
|
import { isAbsolute, join } from 'node:path';
|
|
11
13
|
// node:url: fileURLToPath for proper file:// URL handling
|
|
12
14
|
import { fileURLToPath } from 'node:url';
|
|
13
15
|
import { getModelsCachePath } from '../app/constants';
|
|
14
16
|
import {
|
|
17
|
+
autoDownloadDisabledError,
|
|
15
18
|
downloadFailedError,
|
|
16
19
|
invalidUriError,
|
|
20
|
+
lockFailedError,
|
|
17
21
|
modelNotCachedError,
|
|
18
22
|
modelNotFoundError,
|
|
19
23
|
} from './errors';
|
|
24
|
+
import { getLockPath, getManifestLockPath, withLock } from './lockfile';
|
|
25
|
+
import type { DownloadPolicy } from './policy';
|
|
20
26
|
import type {
|
|
21
27
|
DownloadProgress,
|
|
22
28
|
LlmResult,
|
|
@@ -306,6 +312,85 @@ export class ModelCache {
|
|
|
306
312
|
}
|
|
307
313
|
}
|
|
308
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Ensure a model is available, downloading if necessary.
|
|
317
|
+
* Uses double-check locking pattern for concurrent safety.
|
|
318
|
+
*
|
|
319
|
+
* @param uri - Model URI (hf: or file:)
|
|
320
|
+
* @param type - Model type for manifest
|
|
321
|
+
* @param policy - Download policy (offline, allowDownload)
|
|
322
|
+
* @param onProgress - Optional progress callback
|
|
323
|
+
*/
|
|
324
|
+
async ensureModel(
|
|
325
|
+
uri: string,
|
|
326
|
+
type: ModelType,
|
|
327
|
+
policy: DownloadPolicy,
|
|
328
|
+
onProgress?: ProgressCallback
|
|
329
|
+
): Promise<LlmResult<string>> {
|
|
330
|
+
// Fast path: check if already cached
|
|
331
|
+
const cached = await this.getCachedPath(uri);
|
|
332
|
+
if (cached) {
|
|
333
|
+
return { ok: true, value: cached };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Parse and validate URI
|
|
337
|
+
const parsed = parseModelUri(uri);
|
|
338
|
+
if (!parsed.ok) {
|
|
339
|
+
return { ok: false, error: invalidUriError(uri, parsed.error) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Local files: just verify existence (no download needed)
|
|
343
|
+
if (parsed.value.scheme === 'file') {
|
|
344
|
+
const exists = await this.fileExists(parsed.value.file);
|
|
345
|
+
if (!exists) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: modelNotFoundError(
|
|
349
|
+
uri,
|
|
350
|
+
`File not found: ${parsed.value.file}`
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, value: parsed.value.file };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// HF models: check policy
|
|
358
|
+
if (policy.offline) {
|
|
359
|
+
return { ok: false, error: modelNotCachedError(uri, type) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!policy.allowDownload) {
|
|
363
|
+
return { ok: false, error: autoDownloadDisabledError(uri) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Acquire lock for download (prevents concurrent downloads of same model)
|
|
367
|
+
// Use hash for lock filename to avoid collisions and path issues
|
|
368
|
+
await mkdir(this.dir, { recursive: true });
|
|
369
|
+
const lockName = createHash('sha256')
|
|
370
|
+
.update(uri)
|
|
371
|
+
.digest('hex')
|
|
372
|
+
.slice(0, 32);
|
|
373
|
+
const lockPath = getLockPath(join(this.dir, lockName));
|
|
374
|
+
|
|
375
|
+
const result = await withLock(lockPath, async () => {
|
|
376
|
+
// Double-check: another process may have downloaded while we waited
|
|
377
|
+
const cachedNow = await this.getCachedPath(uri);
|
|
378
|
+
if (cachedNow) {
|
|
379
|
+
return { ok: true as const, value: cachedNow };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Download with progress
|
|
383
|
+
return this.download(uri, type, onProgress);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// withLock returns null if lock acquisition failed
|
|
387
|
+
if (result === null) {
|
|
388
|
+
return { ok: false, error: lockFailedError(uri) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
309
394
|
/**
|
|
310
395
|
* Check if a model is cached/available.
|
|
311
396
|
* For file: URIs, checks if file exists on disk.
|
|
@@ -368,6 +453,7 @@ export class ModelCache {
|
|
|
368
453
|
* If types provided, only clears models of those types.
|
|
369
454
|
*/
|
|
370
455
|
async clear(types?: ModelType[]): Promise<void> {
|
|
456
|
+
// First, read manifest to get paths to delete (outside lock for IO)
|
|
371
457
|
const manifest = await this.loadManifest();
|
|
372
458
|
|
|
373
459
|
const toRemove = types
|
|
@@ -382,14 +468,14 @@ export class ModelCache {
|
|
|
382
468
|
}
|
|
383
469
|
}
|
|
384
470
|
|
|
385
|
-
// Update manifest
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
471
|
+
// Update manifest under lock
|
|
472
|
+
await this.updateManifest((m) => {
|
|
473
|
+
if (types) {
|
|
474
|
+
m.models = m.models.filter((model) => !types.includes(model.type));
|
|
475
|
+
} else {
|
|
476
|
+
m.models = [];
|
|
477
|
+
}
|
|
478
|
+
});
|
|
393
479
|
}
|
|
394
480
|
|
|
395
481
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -410,58 +496,122 @@ export class ModelCache {
|
|
|
410
496
|
return this.manifest;
|
|
411
497
|
}
|
|
412
498
|
|
|
499
|
+
this.manifest = await this.readManifestFromDisk();
|
|
500
|
+
return this.manifest;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Read manifest from disk without cache (for use under lock).
|
|
505
|
+
*/
|
|
506
|
+
private async readManifestFromDisk(): Promise<Manifest> {
|
|
413
507
|
try {
|
|
414
508
|
const content = await readFile(this.manifestPath, 'utf-8');
|
|
415
|
-
|
|
416
|
-
return this.manifest;
|
|
509
|
+
return JSON.parse(content) as Manifest;
|
|
417
510
|
} catch {
|
|
418
511
|
// No manifest or invalid - create empty
|
|
419
|
-
|
|
420
|
-
return this.manifest;
|
|
512
|
+
return { version: MANIFEST_VERSION, models: [] };
|
|
421
513
|
}
|
|
422
514
|
}
|
|
423
515
|
|
|
424
|
-
|
|
516
|
+
/**
|
|
517
|
+
* Atomically update manifest under lock.
|
|
518
|
+
* Uses read-modify-write pattern with cross-process locking to prevent lost updates.
|
|
519
|
+
*/
|
|
520
|
+
private async updateManifest(
|
|
521
|
+
mutator: (manifest: Manifest) => void
|
|
522
|
+
): Promise<void> {
|
|
425
523
|
await mkdir(this.dir, { recursive: true });
|
|
426
|
-
|
|
427
|
-
|
|
524
|
+
|
|
525
|
+
const lockPath = getManifestLockPath(this.dir);
|
|
526
|
+
const result = await withLock(lockPath, async () => {
|
|
527
|
+
// Read current manifest from disk (not cache) under lock
|
|
528
|
+
const manifest = await this.readManifestFromDisk();
|
|
529
|
+
|
|
530
|
+
// Apply mutation
|
|
531
|
+
mutator(manifest);
|
|
532
|
+
|
|
533
|
+
// Write atomically
|
|
534
|
+
await this.writeManifestAtomically(manifest);
|
|
535
|
+
|
|
536
|
+
// Update cache
|
|
537
|
+
this.manifest = manifest;
|
|
538
|
+
return true;
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (result === null) {
|
|
542
|
+
throw new Error('Failed to acquire manifest lock');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Atomically write manifest with fsync for durability.
|
|
548
|
+
* Uses write-to-temp + fsync + rename pattern.
|
|
549
|
+
* Must be called under manifest lock.
|
|
550
|
+
*/
|
|
551
|
+
private async writeManifestAtomically(manifest: Manifest): Promise<void> {
|
|
552
|
+
const tmpPath = `${this.manifestPath}.${process.pid}.tmp`;
|
|
553
|
+
const content = JSON.stringify(manifest, null, 2);
|
|
554
|
+
|
|
555
|
+
// Write to temp file with fsync
|
|
556
|
+
const fh = await open(tmpPath, 'w');
|
|
557
|
+
try {
|
|
558
|
+
await fh.writeFile(content);
|
|
559
|
+
await fh.sync();
|
|
560
|
+
} finally {
|
|
561
|
+
await fh.close();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Atomic rename
|
|
565
|
+
await rename(tmpPath, this.manifestPath);
|
|
566
|
+
|
|
567
|
+
// Fsync parent directory for rename durability (best-effort, not supported on Windows)
|
|
568
|
+
if (process.platform !== 'win32') {
|
|
569
|
+
try {
|
|
570
|
+
const dirFh = await open(this.dir, 'r');
|
|
571
|
+
try {
|
|
572
|
+
await dirFh.sync();
|
|
573
|
+
} finally {
|
|
574
|
+
await dirFh.close();
|
|
575
|
+
}
|
|
576
|
+
} catch {
|
|
577
|
+
// Best-effort durability
|
|
578
|
+
}
|
|
579
|
+
}
|
|
428
580
|
}
|
|
429
581
|
|
|
430
582
|
private async addToManifest(
|
|
431
583
|
uri: string,
|
|
432
584
|
type: ModelType,
|
|
433
|
-
|
|
585
|
+
modelPath: string
|
|
434
586
|
): Promise<void> {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// Get file size and compute checksum
|
|
587
|
+
// Get file size outside lock (IO-bound, doesn't need protection)
|
|
438
588
|
let size = 0;
|
|
439
589
|
try {
|
|
440
|
-
const stats = await stat(
|
|
590
|
+
const stats = await stat(modelPath);
|
|
441
591
|
size = stats.size;
|
|
442
592
|
} catch {
|
|
443
593
|
// Ignore
|
|
444
594
|
}
|
|
445
595
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
596
|
+
await this.updateManifest((manifest) => {
|
|
597
|
+
// Remove existing entry if present
|
|
598
|
+
manifest.models = manifest.models.filter((m) => m.uri !== uri);
|
|
599
|
+
|
|
600
|
+
// Add new entry
|
|
601
|
+
manifest.models.push({
|
|
602
|
+
uri,
|
|
603
|
+
type,
|
|
604
|
+
path: modelPath,
|
|
605
|
+
size,
|
|
606
|
+
checksum: '', // TODO: compute SHA-256 for large files
|
|
607
|
+
cachedAt: new Date().toISOString(),
|
|
608
|
+
});
|
|
457
609
|
});
|
|
458
|
-
|
|
459
|
-
await this.saveManifest(manifest);
|
|
460
610
|
}
|
|
461
611
|
|
|
462
612
|
private async removeFromManifest(uri: string): Promise<void> {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
613
|
+
await this.updateManifest((manifest) => {
|
|
614
|
+
manifest.models = manifest.models.filter((m) => m.uri !== uri);
|
|
615
|
+
});
|
|
466
616
|
}
|
|
467
617
|
}
|
package/src/llm/errors.ts
CHANGED
|
@@ -18,7 +18,9 @@ export type LlmErrorCode =
|
|
|
18
18
|
| 'INFERENCE_FAILED'
|
|
19
19
|
| 'TIMEOUT'
|
|
20
20
|
| 'OUT_OF_MEMORY'
|
|
21
|
-
| 'INVALID_URI'
|
|
21
|
+
| 'INVALID_URI'
|
|
22
|
+
| 'LOCK_FAILED'
|
|
23
|
+
| 'AUTO_DOWNLOAD_DISABLED';
|
|
22
24
|
|
|
23
25
|
export interface LlmError {
|
|
24
26
|
code: LlmErrorCode;
|
|
@@ -91,9 +93,12 @@ export function llmError(
|
|
|
91
93
|
* Check if error is retryable.
|
|
92
94
|
*/
|
|
93
95
|
export function isRetryable(code: LlmErrorCode): boolean {
|
|
94
|
-
return [
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
return [
|
|
97
|
+
'MODEL_DOWNLOAD_FAILED',
|
|
98
|
+
'TIMEOUT',
|
|
99
|
+
'INFERENCE_FAILED',
|
|
100
|
+
'LOCK_FAILED',
|
|
101
|
+
].includes(code);
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -189,3 +194,21 @@ export function invalidUriError(uri: string, details: string): LlmError {
|
|
|
189
194
|
retryable: false,
|
|
190
195
|
});
|
|
191
196
|
}
|
|
197
|
+
|
|
198
|
+
export function lockFailedError(uri: string): LlmError {
|
|
199
|
+
return llmError('LOCK_FAILED', {
|
|
200
|
+
message: `Failed to acquire lock for model download: ${uri}`,
|
|
201
|
+
modelUri: uri,
|
|
202
|
+
retryable: true,
|
|
203
|
+
suggestion: 'Another process may be downloading. Wait and retry.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function autoDownloadDisabledError(uri: string): LlmError {
|
|
208
|
+
return llmError('AUTO_DOWNLOAD_DISABLED', {
|
|
209
|
+
message: `Model not cached and auto-download disabled: ${uri}`,
|
|
210
|
+
modelUri: uri,
|
|
211
|
+
retryable: false,
|
|
212
|
+
suggestion: "Run 'gno models pull' to download models manually.",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process lockfile for model cache operations.
|
|
3
|
+
* Uses O_EXCL create + stale lock recovery pattern.
|
|
4
|
+
*
|
|
5
|
+
* @module src/llm/lockfile
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { open, rename, rm, stat } from 'node:fs/promises';
|
|
9
|
+
// node:os: hostname and user for lock ownership
|
|
10
|
+
import { hostname, userInfo } from 'node:os';
|
|
11
|
+
// node:path: join for manifest lock path
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Constants
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Default lock TTL in milliseconds (24 hours - long to avoid stealing during slow downloads) */
|
|
19
|
+
const DEFAULT_LOCK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** Retry delay for lock acquisition (ms) */
|
|
22
|
+
const LOCK_RETRY_DELAY_MS = 500;
|
|
23
|
+
|
|
24
|
+
/** Max retries before giving up (~10 minutes for multi-GB downloads) */
|
|
25
|
+
const LOCK_MAX_RETRIES = 1200;
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Types
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface LockMeta {
|
|
32
|
+
pid: number;
|
|
33
|
+
hostname: string;
|
|
34
|
+
user: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LockHandle {
|
|
39
|
+
/** Release the lock */
|
|
40
|
+
release: () => Promise<void>;
|
|
41
|
+
/** Path to lock file */
|
|
42
|
+
path: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LockOptions {
|
|
46
|
+
/** Lock TTL in milliseconds (see DEFAULT_LOCK_TTL_MS) */
|
|
47
|
+
ttlMs?: number;
|
|
48
|
+
/** Max retries before giving up (see LOCK_MAX_RETRIES) */
|
|
49
|
+
maxRetries?: number;
|
|
50
|
+
/** Delay between retries in ms (see LOCK_RETRY_DELAY_MS) */
|
|
51
|
+
retryDelayMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Helpers
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function getLockMeta(): LockMeta {
|
|
59
|
+
return {
|
|
60
|
+
pid: process.pid,
|
|
61
|
+
hostname: hostname(),
|
|
62
|
+
user: userInfo().username,
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sleep(ms: number): Promise<void> {
|
|
68
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a lockfile is stale (older than TTL or owner process dead).
|
|
73
|
+
*/
|
|
74
|
+
async function isLockStale(lockPath: string, ttlMs: number): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
const stats = await stat(lockPath);
|
|
77
|
+
const age = Date.now() - stats.mtimeMs;
|
|
78
|
+
|
|
79
|
+
// Lock older than TTL is definitely stale
|
|
80
|
+
if (age > ttlMs) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// TODO: Could also check if PID is alive on same hostname
|
|
85
|
+
// For now, just use TTL-based staleness
|
|
86
|
+
return false;
|
|
87
|
+
} catch {
|
|
88
|
+
// Lock doesn't exist or can't be read
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create lock file exclusively (O_EXCL).
|
|
95
|
+
* Fails if file already exists.
|
|
96
|
+
*/
|
|
97
|
+
async function createLockExclusive(
|
|
98
|
+
lockPath: string,
|
|
99
|
+
meta: LockMeta
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const content = JSON.stringify(meta, null, 2);
|
|
102
|
+
|
|
103
|
+
// Create lock file with O_EXCL - fails if exists
|
|
104
|
+
const fh = await open(lockPath, 'wx');
|
|
105
|
+
try {
|
|
106
|
+
await fh.writeFile(content);
|
|
107
|
+
await fh.sync();
|
|
108
|
+
} finally {
|
|
109
|
+
await fh.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
// Main API
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Acquire a lock on a path.
|
|
119
|
+
* Returns a handle that must be released when done.
|
|
120
|
+
*
|
|
121
|
+
* @param lockPath - Path to the lock file (usually model path + '.lock')
|
|
122
|
+
* @param options - Lock options
|
|
123
|
+
* @returns Lock handle or null if acquisition failed
|
|
124
|
+
*/
|
|
125
|
+
export async function acquireLock(
|
|
126
|
+
lockPath: string,
|
|
127
|
+
options?: LockOptions
|
|
128
|
+
): Promise<LockHandle | null> {
|
|
129
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_LOCK_TTL_MS;
|
|
130
|
+
const maxRetries = options?.maxRetries ?? LOCK_MAX_RETRIES;
|
|
131
|
+
const retryDelayMs = options?.retryDelayMs ?? LOCK_RETRY_DELAY_MS;
|
|
132
|
+
|
|
133
|
+
let retries = 0;
|
|
134
|
+
|
|
135
|
+
while (retries < maxRetries) {
|
|
136
|
+
try {
|
|
137
|
+
// Try to create lock file exclusively
|
|
138
|
+
const meta = getLockMeta();
|
|
139
|
+
await createLockExclusive(lockPath, meta);
|
|
140
|
+
|
|
141
|
+
// Success! Return handle
|
|
142
|
+
return {
|
|
143
|
+
path: lockPath,
|
|
144
|
+
release: async () => {
|
|
145
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// EEXIST means lock exists
|
|
150
|
+
if (e && typeof e === 'object' && 'code' in e && e.code === 'EEXIST') {
|
|
151
|
+
// Check if stale
|
|
152
|
+
const stale = await isLockStale(lockPath, ttlMs);
|
|
153
|
+
|
|
154
|
+
if (stale) {
|
|
155
|
+
// Atomic stale recovery: rename to .stale, then try again
|
|
156
|
+
const stalePath = `${lockPath}.stale.${process.pid}`;
|
|
157
|
+
try {
|
|
158
|
+
await rename(lockPath, stalePath);
|
|
159
|
+
// Clean up stale file (ignore errors)
|
|
160
|
+
await rm(stalePath, { force: true }).catch(() => undefined);
|
|
161
|
+
// Try again immediately
|
|
162
|
+
continue;
|
|
163
|
+
} catch {
|
|
164
|
+
// Someone else grabbed it - retry with backoff
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Lock is held - wait and retry
|
|
169
|
+
retries++;
|
|
170
|
+
await sleep(retryDelayMs);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Other error (permissions, etc.)
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Failed to acquire after max retries
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Execute a function while holding a lock.
|
|
185
|
+
* Automatically releases lock when done.
|
|
186
|
+
*/
|
|
187
|
+
export async function withLock<T>(
|
|
188
|
+
lockPath: string,
|
|
189
|
+
fn: () => Promise<T>,
|
|
190
|
+
options?: LockOptions
|
|
191
|
+
): Promise<T | null> {
|
|
192
|
+
const lock = await acquireLock(lockPath, options);
|
|
193
|
+
if (!lock) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
return await fn();
|
|
199
|
+
} finally {
|
|
200
|
+
await lock.release();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get the lock path for a model file path.
|
|
206
|
+
*/
|
|
207
|
+
export function getLockPath(modelPath: string): string {
|
|
208
|
+
return `${modelPath}.lock`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get the manifest lock path for a cache directory.
|
|
213
|
+
*/
|
|
214
|
+
export function getManifestLockPath(cacheDir: string): string {
|
|
215
|
+
return join(cacheDir, 'manifest.lock');
|
|
216
|
+
}
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Config } from '../../config/types';
|
|
9
9
|
import { ModelCache } from '../cache';
|
|
10
|
+
import type { DownloadPolicy } from '../policy';
|
|
10
11
|
import { getActivePreset, getModelConfig } from '../registry';
|
|
11
12
|
import type {
|
|
12
13
|
EmbeddingPort,
|
|
13
14
|
GenerationPort,
|
|
14
15
|
LlmResult,
|
|
16
|
+
ProgressCallback,
|
|
15
17
|
RerankPort,
|
|
16
18
|
} from '../types';
|
|
17
19
|
import { NodeLlamaCppEmbedding } from './embedding';
|
|
@@ -19,6 +21,20 @@ import { NodeLlamaCppGeneration } from './generation';
|
|
|
19
21
|
import { getModelManager, type ModelManager } from './lifecycle';
|
|
20
22
|
import { NodeLlamaCppRerank } from './rerank';
|
|
21
23
|
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Types
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export interface CreatePortOptions {
|
|
29
|
+
/** Download policy (offline, allowDownload) */
|
|
30
|
+
policy?: DownloadPolicy;
|
|
31
|
+
/** Progress callback for downloads */
|
|
32
|
+
onProgress?: ProgressCallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Default policy: no auto-download (backwards compatible) */
|
|
36
|
+
const DEFAULT_POLICY: DownloadPolicy = { offline: false, allowDownload: false };
|
|
37
|
+
|
|
22
38
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
39
|
// Adapter
|
|
24
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -37,15 +53,23 @@ export class LlmAdapter {
|
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
55
|
* Create an embedding port.
|
|
56
|
+
* With options.policy.allowDownload=true, auto-downloads if not cached.
|
|
40
57
|
*/
|
|
41
58
|
async createEmbeddingPort(
|
|
42
|
-
modelUri?: string
|
|
59
|
+
modelUri?: string,
|
|
60
|
+
options?: CreatePortOptions
|
|
43
61
|
): Promise<LlmResult<EmbeddingPort>> {
|
|
44
62
|
const preset = getActivePreset(this.config);
|
|
45
63
|
const uri = modelUri ?? preset.embed;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const policy = options?.policy ?? DEFAULT_POLICY;
|
|
65
|
+
|
|
66
|
+
// Ensure model is available (downloads if policy allows)
|
|
67
|
+
const resolved = await this.cache.ensureModel(
|
|
68
|
+
uri,
|
|
69
|
+
'embed',
|
|
70
|
+
policy,
|
|
71
|
+
options?.onProgress
|
|
72
|
+
);
|
|
49
73
|
if (!resolved.ok) {
|
|
50
74
|
return resolved;
|
|
51
75
|
}
|
|
@@ -58,15 +82,23 @@ export class LlmAdapter {
|
|
|
58
82
|
|
|
59
83
|
/**
|
|
60
84
|
* Create a generation port.
|
|
85
|
+
* With options.policy.allowDownload=true, auto-downloads if not cached.
|
|
61
86
|
*/
|
|
62
87
|
async createGenerationPort(
|
|
63
|
-
modelUri?: string
|
|
88
|
+
modelUri?: string,
|
|
89
|
+
options?: CreatePortOptions
|
|
64
90
|
): Promise<LlmResult<GenerationPort>> {
|
|
65
91
|
const preset = getActivePreset(this.config);
|
|
66
92
|
const uri = modelUri ?? preset.gen;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
const policy = options?.policy ?? DEFAULT_POLICY;
|
|
94
|
+
|
|
95
|
+
// Ensure model is available (downloads if policy allows)
|
|
96
|
+
const resolved = await this.cache.ensureModel(
|
|
97
|
+
uri,
|
|
98
|
+
'gen',
|
|
99
|
+
policy,
|
|
100
|
+
options?.onProgress
|
|
101
|
+
);
|
|
70
102
|
if (!resolved.ok) {
|
|
71
103
|
return resolved;
|
|
72
104
|
}
|
|
@@ -79,13 +111,23 @@ export class LlmAdapter {
|
|
|
79
111
|
|
|
80
112
|
/**
|
|
81
113
|
* Create a rerank port.
|
|
114
|
+
* With options.policy.allowDownload=true, auto-downloads if not cached.
|
|
82
115
|
*/
|
|
83
|
-
async createRerankPort(
|
|
116
|
+
async createRerankPort(
|
|
117
|
+
modelUri?: string,
|
|
118
|
+
options?: CreatePortOptions
|
|
119
|
+
): Promise<LlmResult<RerankPort>> {
|
|
84
120
|
const preset = getActivePreset(this.config);
|
|
85
121
|
const uri = modelUri ?? preset.rerank;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
122
|
+
const policy = options?.policy ?? DEFAULT_POLICY;
|
|
123
|
+
|
|
124
|
+
// Ensure model is available (downloads if policy allows)
|
|
125
|
+
const resolved = await this.cache.ensureModel(
|
|
126
|
+
uri,
|
|
127
|
+
'rerank',
|
|
128
|
+
policy,
|
|
129
|
+
options?.onProgress
|
|
130
|
+
);
|
|
89
131
|
if (!resolved.ok) {
|
|
90
132
|
return resolved;
|
|
91
133
|
}
|