All files / src vault-registry.ts

100% Statements 116/116
100% Branches 59/59
100% Functions 26/26
100% Lines 116/116

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 164 165 166 167 168 169 170 171 172 173 1741x                               1x 1x   1x 380x 380x 380x   1x 384x 384x 384x 384x 384x 384x 384x   1x 78x 78x   1x 70x 70x   1x 72x 72x   1x 56x 56x   102x 102x 102x   278x 278x 278x   150x 150x 150x   152x 152x 152x   94x 94x 94x 94x 94x 94x   376x 376x 56x 56x 100x 1x 1x 376x   9x 9x 9x 9x 9x 1x 1x 9x             1x 1x   1x 79x 79x 79x   1x 23x 23x   1x 207x 207x   1x 129x 129x   1x 70x 70x 70x 70x   1x 3x 3x 2x 3x   1x 1x 1x 1x     1x 56x 56x   1x 90x 90x           1x 71x 71x 11x 71x 71x             1x 58x 58x 58x   1x 10x 10x 3x 2x 2x 4x   1x 10x 1x  
/**
 * 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);
    }
 
    markActive(id: string, when: number = Date.now()): void {
        const entry = this.get(id);
        if (!entry) return;
        this.upsert({ ...entry, lastActiveAt: when });
    }
 
    remove(id: string): void {
        const entries = this.list().filter((e) => e.id !== id);
        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
        }
    }
}