All files / src vault-registry.ts

100% Statements 57/57
100% Branches 28/28
100% Functions 26/26
100% Lines 48/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163                                8x 8x     479x 478x       483x 483x               198x       109x       98x       97x       675x       389x       229x       244x       154x 154x 154x 154x       1046x 64x 64x   2x         11x 11x 11x   1x                   498x     615x 615x       60x       295x       198x       94x 94x 94x         99x       136x               103x 103x 11x 10x                 90x 90x       16x 16x 4x 3x 3x            
/**
 * Shared-root layout + per-vault registry + cross-process lock.
 * One lilbee binary, one HF cache, many vault data-dirs, one active vault at a time.
 */
import { node } from "./binary-manager";
import { getDefaultLilbeeDataRoot } from "./session-token";
import {
    DEFAULT_SHARED_CONFIG,
    LOCK_STATE,
    SHARED_PATH,
    type ActiveLock,
    type LockState,
    type SharedConfig,
    type VaultRegistryEntry,
} from "./types";
 
const VAULT_ID_BYTES = 6;
const FALLBACK_SHARED_ROOT = "/tmp/lilbee";
 
export function resolveSharedRoot(setting: string): string {
    if (setting && setting.length > 0) return setting;
    return getDefaultLilbeeDataRoot() ?? FALLBACK_SHARED_ROOT;
}
 
export function computeVaultId(vaultPath: string): string {
    const canonical = node.resolve(vaultPath);
    return node
        .createHash("sha256")
        .update(canonical)
        .digest("hex")
        .slice(0, VAULT_ID_BYTES * 2);
}
 
export function sharedBinDir(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.BIN);
}
 
export function sharedModelsDir(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.MODELS);
}
 
export function vaultsRootDir(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.VAULTS);
}
 
export function defaultDataDirFor(sharedRoot: string, vaultId: string): string {
    return node.join(vaultsRootDir(sharedRoot), vaultId);
}
 
function configPath(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.CONFIG);
}
 
function registryPath(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.REGISTRY);
}
 
function lockPath(sharedRoot: string): string {
    return node.join(sharedRoot, SHARED_PATH.LOCK);
}
 
function ensureDir(path: string): void {
    if (!node.existsSync(path)) node.mkdirSync(path, { recursive: true });
}
 
function writeJsonAtomic(path: string, value: unknown): void {
    ensureDir(node.dirname(path));
    const tmp = `${path}.tmp`;
    node.writeFileSync(tmp, JSON.stringify(value, null, 2));
    node.renameSync(tmp, path);
}
 
function readJson<T>(path: string): T | null {
    if (!node.existsSync(path)) return null;
    try {
        return JSON.parse(node.readFileSync(path, "utf-8")) as T;
    } catch {
        return null;
    }
}
 
function isProcessAlive(pid: number): boolean {
    try {
        node.processKill(pid, 0);
        return true;
    } catch {
        return false;
    }
}
 
/**
 * Manages `<shared-root>/{config.json, registry.json, active.lock}`.
 * All file I/O is synchronous because the call sites are plugin lifecycle
 * events that already block on disk.
 */
export class VaultRegistry {
    constructor(public readonly sharedRoot: string) {}
 
    loadConfig(): SharedConfig {
        const parsed = readJson<Partial<SharedConfig>>(configPath(this.sharedRoot));
        return { ...DEFAULT_SHARED_CONFIG, ...(parsed ?? {}) };
    }
 
    saveConfig(config: SharedConfig): void {
        writeJsonAtomic(configPath(this.sharedRoot), config);
    }
 
    list(): VaultRegistryEntry[] {
        return readJson<VaultRegistryEntry[]>(registryPath(this.sharedRoot)) ?? [];
    }
 
    get(id: string): VaultRegistryEntry | null {
        return this.list().find((e) => e.id === id) ?? null;
    }
 
    upsert(entry: VaultRegistryEntry): void {
        const entries = this.list().filter((e) => e.id !== entry.id);
        entries.push(entry);
        writeJsonAtomic(registryPath(this.sharedRoot), entries);
    }
 
    /** Return the registered data-dir or the default location for this id. */
    resolveDataDir(id: string): string {
        return this.get(id)?.dataDir ?? defaultDataDirFor(this.sharedRoot, id);
    }
 
    readLock(): ActiveLock | null {
        return readJson<ActiveLock>(lockPath(this.sharedRoot));
    }
 
    /**
     * Classify the current lock against our vault id. STALE means a lock file
     * exists but the owning PID is gone — safe to take.
     */
    lockState(vaultId: string): LockState {
        const lock = this.readLock();
        if (lock === null) return LOCK_STATE.NONE;
        if (!isProcessAlive(lock.pid)) return LOCK_STATE.STALE;
        return lock.vaultId === vaultId ? LOCK_STATE.OURS : LOCK_STATE.LIVE_OTHER;
    }
 
    /**
     * Atomically write a lock claiming the shared root for *vaultId*.
     * Caller must already have decided the existing lock (if any) is takeable
     * (STALE, OURS, or LIVE_OTHER after the user authorized take-over).
     */
    writeLock(lock: ActiveLock): void {
        ensureDir(this.sharedRoot);
        node.writeFileSync(lockPath(this.sharedRoot), JSON.stringify(lock, null, 2));
    }
 
    releaseLock(vaultId: string): void {
        const lock = this.readLock();
        if (lock === null) return;
        if (lock.vaultId !== vaultId) return;
        try {
            node.unlinkSync(lockPath(this.sharedRoot));
        } catch {
            // already gone — fine
        }
    }
}