import path from "node:path";
import type { SecafsRollbackState } from "./rollback-types.js";
* Minimal projection of SessionEntry fields the scanner reads. Keeping the
* type local avoids deep-importing core types into the plugin.
*/
export interface SessionEntryView {
sessionId?: string;
updatedAt?: number;
kind?: "secafs";
mountState?: "mounted" | "unmounted" | "stuck";
secafsRollback?: SecafsRollbackState;
}
export interface IdleScannerDeps {
loadStore: () => Record<string, SessionEntryView>;
patchSession: (key: string, patch: Record<string, unknown>) => Promise<void>;
rpc: {
list: () => Promise<{ mounts: Array<{ conversationId: string }> }>;
mount: (p: {
conversationId: string;
hostPath?: string;
}) => Promise<{ hostPath: string; mounted: boolean }>;
unmount: (p: { conversationId: string }) => Promise<{ unmounted: boolean }>;
};
workspace: {
set: (key: string, spec: { path: string }) => Promise<void>;
};
mountRoot: string;
* comparison between the mountpoint and the mount root. */
probeMount?: (sid: string) => Promise<boolean | null>;
now?: () => number;
logger?: { info?: (m: string) => void; warn?: (m: string) => void };
}
export interface IdleScannerOptions {
* Seconds since `updatedAt` after which a mounted volume is unmounted.
* `0` disables the idle-unmount duty (the keeper still runs).
*/
idleUnmountSeconds: number;
idleScanSeconds: number;
}
* Periodic volume scanner with two duties:
*
* 1. MOUNT-KEEPER (always on): remount sessions whose store entry says
* `mountState: "mounted"` but whose volume the daemon does not actually
* have — the daemon dying (crash + supervisor respawn) takes every FUSE
* mount with it while the store still says mounted. Without the keeper,
* the next direct chat to such a session aborts with upstream's
* WorkspaceVanishedError (its attestation check runs BEFORE plugin hooks,
* so the before_prompt_build auto-mount never gets a chance).
*
* 2. IDLE-UNMOUNT (opt-in via idleUnmountSeconds > 0): unmount volumes idle
* longer than the threshold. NOTE this re-creates the vanished-workspace
* trap for directly-addressed sessions (see above) — the lazy remount via
* before_prompt_build fires too late on the embedded-run path. Only
* enable it for deployments that reopen sessions explicitly.
*
* Returns a `stop()` handle that clears the interval.
*/
export function startIdleScanner(
deps: IdleScannerDeps,
opts: IdleScannerOptions,
): { stop: () => void } {
if (opts.idleScanSeconds <= 0) {
return { stop: () => {} };
}
const idleMs = opts.idleUnmountSeconds * 1000;
const intervalMs = opts.idleScanSeconds * 1000;
const now = deps.now ?? (() => Date.now());
const handle = setInterval(() => {
void scanOnce(deps, idleMs, now);
}, intervalMs);
if (typeof handle === "object" && handle !== null && "unref" in handle) {
(handle as unknown as { unref: () => void }).unref();
}
return {
stop: () => clearInterval(handle),
};
}
* Single-pass scan. Exposed for tests. `idleMs <= 0` runs only the
* mount-keeper duty.
*/
export async function scanOnce(
deps: IdleScannerDeps,
idleMs: number,
now: () => number,
): Promise<void> {
let store: Record<string, SessionEntryView>;
try {
store = deps.loadStore();
} catch (err) {
deps.logger?.warn?.(
`[secafs-chat] idle scanner: failed to load session store: ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
let daemonMounts: Set<string> | null = null;
try {
const r = await deps.rpc.list();
daemonMounts = new Set(r.mounts.map((m) => m.conversationId));
} catch (err) {
deps.logger?.warn?.(
`[secafs-chat] mount-keeper: rpc.list failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
if (daemonMounts) {
const lost = new Map<string, string>();
for (const [key, entry] of Object.entries(store)) {
if (entry?.kind !== "secafs" || entry.mountState !== "mounted") {
continue;
}
if (entry.secafsRollback?.inProgress) {
continue;
}
const sid = sidFromKey(key);
if (!sid || lost.has(sid)) {
continue;
}
if (!daemonMounts.has(sid)) {
lost.set(sid, key);
continue;
}
const live = await (deps.probeMount ?? ((s2: string) => isLiveMount(deps, s2)))(sid);
if (live === false) {
lost.set(sid, key);
}
}
for (const [sid, key] of lost) {
try {
try {
await deps.rpc.unmount({ conversationId: sid });
} catch {
}
const result = await deps.rpc.mount({
conversationId: sid,
hostPath: path.join(deps.mountRoot, sid),
});
await deps.workspace.set(key, { path: result.hostPath });
deps.logger?.info?.(
`[secafs-chat] mount-keeper: re-mounted ${key} → ${result.hostPath} (mount was lost)`,
);
} catch (err) {
deps.logger?.warn?.(
`[secafs-chat] mount-keeper: re-mount failed for ${key}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
if (idleMs <= 0) {
return;
}
const cutoff = now() - idleMs;
for (const [key, entry] of Object.entries(store)) {
if (entry?.kind !== "secafs") {
continue;
}
if (entry.mountState !== "mounted") {
continue;
}
if (entry.secafsRollback?.inProgress) {
continue;
}
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
if (updatedAt > cutoff) {
continue;
}
const sid = sidFromKey(key);
if (!sid) {
continue;
}
try {
await deps.rpc.unmount({ conversationId: sid });
await deps.patchSession(key, { mountState: "unmounted" });
deps.logger?.info?.(`[secafs-chat] idle-unmounted ${key} (idle ≥ ${idleMs / 1000}s)`);
} catch (err) {
deps.logger?.warn?.(
`[secafs-chat] idle-unmount failed for ${key}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
* Probe whether the FUSE mount for `sid` is actually live in the kernel.
* Returns true (live), false (dead/absent), or null (indeterminate — fail
* open, do nothing). A mounted FUSE volume sits on a different st_dev than
* the mount root; a dead FUSE session surfaces as ENOTCONN on stat.
*/
async function isLiveMount(deps: IdleScannerDeps, sid: string): Promise<boolean | null> {
const fs = await import("node:fs/promises");
const hostPath = path.join(deps.mountRoot, sid);
try {
const [mp, root] = await Promise.all([fs.stat(hostPath), fs.stat(deps.mountRoot)]);
return mp.dev !== root.dev;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOTCONN") return false;
if (code === "ENOENT") return false;
return null;
}
}
function sidFromKey(sessionKey: string): string {
const parts = sessionKey.split(":").filter((p) => p.length > 0);
return parts.length >= 2 ? parts[parts.length - 1] : sessionKey;
}