import fs from "node:fs/promises";
import path from "node:path";
import { readSecafsFile, writeSecafsFile } from "./fs-methods.js";
import type { SecafsRollbackState } from "./rollback-types.js";
export interface RegisterGatewayMethodsOpts {
registerGatewayMethod: (name: string, handler: (params: unknown) => Promise<unknown>) => void;
rpc: {
ping(): Promise<{ version: string; pgConnected: boolean; mountCount: number }>;
list(): Promise<{
mounts: Array<{ conversationId: string; hostPath: string; since: string }>;
}>;
mount(p: {
conversationId: string;
hostPath?: string;
}): Promise<{ hostPath: string; mounted: boolean }>;
unmount(p: { conversationId: string }): Promise<{ unmounted: boolean }>;
destroy(p: { conversationId: string }): Promise<{ destroyed: boolean }>;
snapshotEnable(p: {
conversationId: string;
}): Promise<{ enabled: boolean; currentSnapId: number }>;
snapshotDisable(p: {
conversationId: string;
}): Promise<{ disabled: boolean; purgedSnapshots: number; purgedUndoRows: number }>;
snapshotCommit(p: {
conversationId: string;
label?: string;
}): Promise<{ snapId: number; committedAt: string; label: string | null }>;
snapshotList(p: { conversationId: string }): Promise<{
snapshots: Array<{ snapId: number; committedAt: string; label: string | null }>;
}>;
snapshotRestore(p: {
conversationId: string;
snapId: number;
}): Promise<{ restored: boolean; prunedSnapshots: number; prunedUndoRows: number }>;
};
sessions: {
create(opts: { kind: "secafs" }): Promise<{ sessionKey: string }>;
patch(key: string, p: Record<string, unknown>): Promise<void>;
load(key: string): Promise<{ sessionId: string } | null>;
delete(key: string): Promise<void>;
entries(): Promise<Record<string, Record<string, unknown>>>;
};
workspace: {
set(key: string, spec: { path: string }): Promise<void>;
clear(key: string): Promise<void>;
};
mountRoot: string;
* Optional session store for reading per-session rollback state (e.g. enabled flag).
* When provided, `secafs.rollback.list` will derive the `enabled` field from the
* stored `secafsRollback` entry instead of defaulting to `false`.
*/
sessionStore?: {
load(key: string): Promise<{ secafsRollback?: SecafsRollbackState } | null>;
};
* Orchestration handler for restore operations. Called by `secafs.rollback.restore`.
* Provided by the plugin's index.ts (Task 3.9).
*/
handleRestore: (args: { sessionKey: string; snapId: number }) => Promise<{
restored: true;
restoredMessageId: string;
prunedSnapshots: number;
prunedUndoRows: number;
}>;
* Eagerly snapshot the just-finished turn and return its snapId (the lazy
* before_prompt_build hook only commits at the start of the NEXT turn).
* Backs `secafs.rollback.snapshot`, used by the per-message rollback button.
*/
snapshotNow?: (args: { sessionKey: string }) => Promise<{
enabled: boolean;
committed: boolean;
snapId?: number;
messageId?: string;
}>;
* Feature flag for the rollback UI. When `false`, the three
* `secafs.rollback.*` gateway methods are NOT registered. Default `true`.
*/
enableRollbackUI?: boolean;
* Configurable session-key prefixes. Defaults match OpenClaw's defaults
* (`mainKey="main"`, `agentId="main"`) so existing setups behave exactly
* as before. Used to build the bare and canonical key forms during
* destroy so we delete both halves of a conversation regardless of the
* deployment's renaming choices.
*/
mainKey?: string;
defaultAgentId?: string;
* Absolute path of the default agent workspace. When provided, the contents
* are seeded into a freshly-created SecAFS volume so the new conversation
* inherits the agent's identity/memory files (AGENTS.md, IDENTITY.md,
* USER.md, SOUL.md, etc.) instead of presenting an empty BOOTSTRAP-pending
* workspace. Set to `undefined` to disable seeding.
*/
defaultWorkspaceDir?: string;
* Resolve the absolute path to the chat history JSONL file for a sessionId.
* Used when pre-seeding the canonical session entry so the agent runtime
* finds a valid sessionFile before its first prompt.
*/
sessionFileFor?: (sessionId: string) => string;
* Create a session-store entry under `key` if one does not already exist;
* no-op if it does. Used to pre-seed the canonical-form entry so the agent
* runtime does not later auto-create it with an invalid sessionId.
*/
ensureSessionEntry?: (key: string, entry: Record<string, unknown>) => Promise<void>;
* Delete the conversation's chat transcript files (.jsonl and
* .trajectory.jsonl) as part of destroy. Called before the store entries
* are removed (the paths come from entry.sessionFile). Best-effort.
*/
deleteSessionArtifacts?: (sid: string) => Promise<void>;
* Read the conversation's transcript files for export. Returns base64
* contents (fields undefined when the file is missing).
*/
readTranscripts?: (sid: string) => Promise<{
sessionJsonl?: string;
trajectoryJsonl?: string;
}>;
* Write imported transcript files for a fresh conversation. Contents are
* base64; the implementation owns path resolution (sid-named files in the
* session store directory).
*/
writeTranscripts?: (
sid: string,
chat: { sessionJsonl?: string; trajectoryJsonl?: string },
) => Promise<void>;
logger?: { info?: (msg: string) => void; warn?: (msg: string) => void };
}
export interface SessionArchive {
manifest: {
schemaVersion: 1;
sid: string;
alias: string | null;
exportedAt: string;
rollbackEnabled: boolean;
fileCount: number;
totalBytes: number;
};
files: Array<{ path: string; content: string }>;
chat: { sessionJsonl?: string; trajectoryJsonl?: string };
}
* Names that should NOT be copied from the default workspace into a new SecAFS
* volume. `.git/` is per-workspace and large; `.openclaw/` holds bootstrap
* state that must be derived from the new volume's own files; transient
* heartbeat scratch files are session-specific.
*/
const SEED_EXCLUDE_NAMES = new Set([".git", ".openclaw", "node_modules"]);
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
const MAX_ARCHIVE_FILES = 5000;
* Recursively collect every regular file under `root` as
* {path: relative, content: base64}. Symlinks and special files are skipped
* (the agent workspace doesn't use them; SecAFS symlink rows would need a
* dedicated archive field). Throws when caps are exceeded.
*/
async function collectExportFiles(root: string): Promise<Array<{ path: string; content: string }>> {
const out: Array<{ path: string; content: string }> = [];
let totalBytes = 0;
async function walk(dir: string, rel: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(dir, entry.name);
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await walk(abs, relPath);
continue;
}
if (!entry.isFile()) {
continue;
}
const buf = await fs.readFile(abs);
totalBytes += buf.byteLength;
if (totalBytes > MAX_ARCHIVE_BYTES) {
throw new Error(`workspace exceeds export limit of ${MAX_ARCHIVE_BYTES / (1024 * 1024)}MB`);
}
out.push({ path: relPath, content: buf.toString("base64") });
if (out.length > MAX_ARCHIVE_FILES) {
throw new Error(`workspace exceeds export limit of ${MAX_ARCHIVE_FILES} files`);
}
}
}
await walk(root, "");
return out;
}
interface TreeEntry {
name: string;
kind: "file" | "dir";
size?: number;
children?: TreeEntry[];
truncated?: boolean;
}
* Walk `root` and return a depth-limited, entry-capped tree representation.
* Hidden directories and the `.openclaw/` state dir are skipped to keep the
* payload small and the rendered tree focused on user-visible content. The
* traversal is best-effort: unreadable entries are silently dropped rather
* than aborting the whole tree.
*/
async function readTree(params: {
root: string;
maxEntries: number;
maxDepth: number;
}): Promise<TreeEntry[]> {
const skipNames = new Set([".git", ".openclaw"]);
let remaining = params.maxEntries;
async function walk(dir: string, depth: number): Promise<TreeEntry[]> {
if (depth > params.maxDepth || remaining <= 0) {
return [];
}
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) {
return a.isDirectory() ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const result: TreeEntry[] = [];
for (const entry of entries) {
if (skipNames.has(entry.name)) {
continue;
}
if (remaining <= 0) {
result.push({ name: "…", kind: "file", truncated: true });
break;
}
remaining -= 1;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push({
name: entry.name,
kind: "dir",
children: await walk(full, depth + 1),
});
} else {
let size: number | undefined;
try {
const st = await fs.stat(full);
size = st.size;
} catch {
}
result.push({ name: entry.name, kind: "file", size });
}
}
return result;
}
return walk(params.root, 0);
}
async function seedFromDefaultWorkspace(params: {
sourceDir: string;
destDir: string;
logger?: { info?: (msg: string) => void; warn?: (msg: string) => void };
}): Promise<void> {
const { sourceDir, destDir, logger } = params;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(sourceDir, { withFileTypes: true });
} catch (err) {
logger?.warn?.(
`[secafs-chat] seed skipped: cannot read default workspace ${sourceDir}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
for (const entry of entries) {
if (SEED_EXCLUDE_NAMES.has(entry.name)) {
continue;
}
const src = path.join(sourceDir, entry.name);
const dst = path.join(destDir, entry.name);
try {
await fs.cp(src, dst, { recursive: true, errorOnExist: false, force: false });
} catch (err) {
logger?.warn?.(
`[secafs-chat] seed: failed to copy ${entry.name}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
function normalizeAlias(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
const alias = raw.trim();
return alias ? alias.slice(0, 64) : undefined;
}
function sidFromKey(sessionKey: string): string {
const parts = sessionKey.split(":").filter((p) => p.length > 0);
return parts.length >= 2 ? parts[parts.length - 1] : sessionKey;
}
export function registerGatewayMethods(opts: RegisterGatewayMethodsOpts): void {
const buildKeyForms = (sessionKey: string): string[] => {
const sid = sidFromKey(sessionKey);
const mk = opts.mainKey ?? "main";
const aid = opts.defaultAgentId ?? "main";
const bare = `${mk}:${sid}`;
const canonical = `agent:${aid}:${mk}:${sid}`;
const set = new Set<string>([sessionKey, bare, canonical]);
return [...set];
};
const loadAnyForm = async (
sessionKey: string,
): Promise<{ secafsRollback?: SecafsRollbackState } | null> => {
if (!opts.sessionStore) return null;
for (const k of buildKeyForms(sessionKey)) {
const entry = await opts.sessionStore.load(k);
if (entry?.secafsRollback !== undefined) return entry;
}
return null;
};
const patchAllForms = async (
sessionKey: string,
patch: Record<string, unknown>,
): Promise<void> => {
for (const k of buildKeyForms(sessionKey)) {
try {
await opts.sessions.patch(k, patch);
} catch {
}
}
};
opts.registerGatewayMethod("secafs.status", async () => {
try {
const p = await opts.rpc.ping();
return {
daemonReachable: true,
pgConnected: p.pgConnected,
mountCount: p.mountCount,
};
} catch {
return { daemonReachable: false, pgConnected: false, mountCount: 0 };
}
});
opts.registerGatewayMethod("secafs.session.create", async (raw: unknown) => {
const params = (raw ?? {}) as { alias?: string };
const alias = normalizeAlias(params.alias);
const { sessionKey } = await opts.sessions.create({ kind: "secafs" });
const sid = sidFromKey(sessionKey);
const hostPath = path.join(opts.mountRoot, sid);
const result = await opts.rpc.mount({ conversationId: sid, hostPath });
if (opts.defaultWorkspaceDir) {
await seedFromDefaultWorkspace({
sourceDir: opts.defaultWorkspaceDir,
destDir: result.hostPath,
logger: opts.logger,
});
}
const mk = opts.mainKey ?? "main";
const aid = opts.defaultAgentId ?? "main";
const canonicalKey = `agent:${aid}:${mk}:${sid}`;
if (canonicalKey !== sessionKey && opts.ensureSessionEntry) {
const sessionFile = opts.sessionFileFor?.(sid);
await opts.ensureSessionEntry(canonicalKey, {
sessionId: sid,
...(sessionFile ? { sessionFile } : {}),
updatedAt: Date.now(),
});
}
await opts.workspace.set(sessionKey, { path: result.hostPath });
await patchAllForms(sessionKey, { mountState: "mounted" });
const marker = { kind: "secafs", ...(alias ? { alias } : {}) };
await opts.sessions.patch(sessionKey, marker);
if (canonicalKey !== sessionKey) {
await opts.sessions.patch(canonicalKey, marker);
}
opts.logger?.info?.(
`[secafs-chat] session created and mounted: ${sessionKey} → ${result.hostPath}`,
);
return { sessionKey, hostPath: result.hostPath, ...(alias ? { alias } : {}) };
});
opts.registerGatewayMethod("secafs.session.open", async (raw: unknown) => {
const params = raw as { sessionKey: string };
const sid = sidFromKey(params.sessionKey);
const hostPath = path.join(opts.mountRoot, sid);
const result = await opts.rpc.mount({ conversationId: sid, hostPath });
const canonicalKey = `agent:${opts.defaultAgentId ?? "main"}:${opts.mainKey ?? "main"}:${sid}`;
if (canonicalKey !== params.sessionKey && opts.ensureSessionEntry) {
const sessionFile = opts.sessionFileFor?.(sid);
await opts.ensureSessionEntry(canonicalKey, {
sessionId: sid,
...(sessionFile ? { sessionFile } : {}),
updatedAt: Date.now(),
});
}
await opts.workspace.set(params.sessionKey, { path: result.hostPath });
await patchAllForms(params.sessionKey, { mountState: "mounted" });
return { hostPath: result.hostPath };
});
opts.registerGatewayMethod("secafs.session.close", async (raw: unknown) => {
const params = raw as { sessionKey: string };
const sid = sidFromKey(params.sessionKey);
const result = await opts.rpc.unmount({ conversationId: sid });
await opts.workspace.clear(params.sessionKey);
await patchAllForms(params.sessionKey, { mountState: "unmounted" });
return { unmounted: result.unmounted };
});
opts.registerGatewayMethod("secafs.session.list", async () => {
const mk = opts.mainKey ?? "main";
const entries = await opts.sessions.entries();
let mounts: Array<{ conversationId: string; hostPath: string; since: string }> = [];
let daemonReachable = true;
try {
mounts = (await opts.rpc.list()).mounts;
} catch {
daemonReachable = false;
}
const mountBySid = new Map(mounts.map((m) => [m.conversationId, m]));
const groups = new Map<
string,
{ alias?: string; isSecafs: boolean; updatedAt: number; lastInteractionAt: number }
>();
for (const [key, e] of Object.entries(entries)) {
const sid = sidFromKey(key);
if (sid === key) continue;
const g = groups.get(sid) ?? { isSecafs: false, updatedAt: 0, lastInteractionAt: 0 };
const entry = e as {
kind?: string;
alias?: string;
spawnedBy?: string;
spawnedWorkspaceDir?: string;
updatedAt?: number;
lastInteractionAt?: number;
};
if (
entry.kind === "secafs" ||
entry.spawnedBy?.endsWith(":secafs-chat") ||
entry.spawnedWorkspaceDir?.startsWith(opts.mountRoot + path.sep)
) {
g.isSecafs = true;
}
if (entry.alias && !g.alias) g.alias = entry.alias;
g.updatedAt = Math.max(g.updatedAt, Number(entry.updatedAt ?? 0));
g.lastInteractionAt = Math.max(g.lastInteractionAt, Number(entry.lastInteractionAt ?? 0));
groups.set(sid, g);
}
const sessions = [];
for (const [sid, g] of groups) {
if (!g.isSecafs) continue;
const m = mountBySid.get(sid);
sessions.push({
sid,
sessionKey: `${mk}:${sid}`,
alias: g.alias ?? null,
mounted: Boolean(m),
hostPath: m?.hostPath ?? path.join(opts.mountRoot, sid),
updatedAt: g.updatedAt || null,
lastInteractionAt: g.lastInteractionAt || null,
});
}
sessions.sort(
(a, b) =>
Math.max(b.lastInteractionAt ?? 0, b.updatedAt ?? 0) -
Math.max(a.lastInteractionAt ?? 0, a.updatedAt ?? 0),
);
return { daemonReachable, sessions };
});
opts.registerGatewayMethod("secafs.session.rename", async (raw: unknown) => {
const params = raw as { sessionKey: string; alias?: string };
const sid = sidFromKey(params.sessionKey);
const alias = normalizeAlias(params.alias);
const mk = opts.mainKey ?? "main";
const aid = opts.defaultAgentId ?? "main";
const keys = [...new Set([`${mk}:${sid}`, `agent:${aid}:${mk}:${sid}`, params.sessionKey])];
for (const key of keys) {
await opts.sessions.patch(key, { alias: alias ?? null });
}
return { sid, alias: alias ?? null };
});
opts.registerGatewayMethod("secafs.session.export", async (raw: unknown) => {
const params = raw as { sessionKey: string };
const sid = sidFromKey(params.sessionKey);
const hostPath = path.join(opts.mountRoot, sid);
await opts.rpc.mount({ conversationId: sid, hostPath });
const files = await collectExportFiles(hostPath);
const chat = (await opts.readTranscripts?.(sid)) ?? {};
let alias: string | null = null;
let rollbackEnabled = false;
const entries = await opts.sessions.entries();
for (const key of buildKeyForms(params.sessionKey)) {
const e = entries[key] as
| { alias?: string; secafsRollback?: { enabled?: boolean } }
| undefined;
if (e?.alias && !alias) alias = e.alias;
if (e?.secafsRollback?.enabled) rollbackEnabled = true;
}
const totalBytes = files.reduce((n, f) => n + Buffer.byteLength(f.content, "base64"), 0);
const archive: SessionArchive = {
manifest: {
schemaVersion: 1,
sid,
alias,
exportedAt: new Date().toISOString(),
rollbackEnabled,
fileCount: files.length,
totalBytes,
},
files,
chat,
};
return archive;
});
opts.registerGatewayMethod("secafs.session.import", async (raw: unknown) => {
const params = raw as { archive?: SessionArchive; alias?: string };
const archive = params.archive;
if (!archive || archive.manifest?.schemaVersion !== 1 || !Array.isArray(archive.files)) {
throw new Error("invalid archive: expected {manifest:{schemaVersion:1}, files:[...]}");
}
let importBytes = 0;
for (const f of archive.files) {
if (typeof f?.path !== "string" || typeof f?.content !== "string") {
throw new Error("invalid archive: each file needs {path, content}");
}
importBytes += Buffer.byteLength(f.content, "base64");
if (importBytes > MAX_ARCHIVE_BYTES) {
throw new Error(`archive exceeds ${MAX_ARCHIVE_BYTES / (1024 * 1024)}MB limit`);
}
}
const { sessionKey } = await opts.sessions.create({ kind: "secafs" });
const sid = sidFromKey(sessionKey);
const hostPath = path.join(opts.mountRoot, sid);
const result = await opts.rpc.mount({ conversationId: sid, hostPath });
let filesWritten = 0;
for (const f of archive.files) {
await writeSecafsFile(opts.mountRoot, sid, f.path, f.content, { encoding: "base64" });
filesWritten += 1;
}
if (opts.writeTranscripts && (archive.chat?.sessionJsonl || archive.chat?.trajectoryJsonl)) {
await opts.writeTranscripts(sid, archive.chat);
}
const mk2 = opts.mainKey ?? "main";
const aid2 = opts.defaultAgentId ?? "main";
const canonicalKey = `agent:${aid2}:${mk2}:${sid}`;
if (canonicalKey !== sessionKey && opts.ensureSessionEntry) {
const sessionFile = opts.sessionFileFor?.(sid);
await opts.ensureSessionEntry(canonicalKey, {
sessionId: sid,
...(sessionFile ? { sessionFile } : {}),
updatedAt: Date.now(),
});
}
await opts.workspace.set(sessionKey, { path: result.hostPath });
const alias = normalizeAlias(params.alias) ?? normalizeAlias(archive.manifest.alias);
const marker = {
kind: "secafs",
mountState: "mounted",
...(alias ? { alias } : {}),
};
await opts.sessions.patch(sessionKey, marker);
if (canonicalKey !== sessionKey) {
await opts.sessions.patch(canonicalKey, marker);
}
opts.logger?.info?.(
`[secafs-chat] imported session ${sessionKey} (${filesWritten} files) → ${result.hostPath}`,
);
return { sessionKey, hostPath: result.hostPath, filesWritten, alias: alias ?? null };
});
opts.registerGatewayMethod("secafs.tree", async (raw: unknown) => {
const params = raw as { sessionKey: string; maxEntries?: number; maxDepth?: number };
const sid = sidFromKey(params.sessionKey);
const hostPath = path.join(opts.mountRoot, sid);
const maxEntries = Math.max(1, Math.min(params.maxEntries ?? 500, 5000));
const maxDepth = Math.max(1, Math.min(params.maxDepth ?? 6, 12));
const entries = await readTree({ root: hostPath, maxEntries, maxDepth });
return { hostPath, entries };
});
opts.registerGatewayMethod("secafs.fs.read", async (raw: unknown) => {
const p = raw as {
sessionKey: string;
path: string;
encoding?: "utf8" | "base64";
maxBytes?: number;
};
const sid = sidFromKey(p.sessionKey);
return readSecafsFile(opts.mountRoot, sid, p.path, {
encoding: p.encoding,
maxBytes: p.maxBytes,
});
});
opts.registerGatewayMethod("secafs.fs.write", async (raw: unknown) => {
const p = raw as {
sessionKey: string;
path: string;
content: string;
encoding?: "utf8" | "base64";
};
const sid = sidFromKey(p.sessionKey);
return writeSecafsFile(opts.mountRoot, sid, p.path, p.content, { encoding: p.encoding });
});
if (opts.enableRollbackUI !== false) {
opts.registerGatewayMethod("secafs.rollback.setEnabled", async (raw: unknown) => {
const p = raw as { sessionKey?: string; enabled?: boolean } | undefined;
if (!p?.sessionKey || typeof p.enabled !== "boolean") {
throw new Error("sessionKey and enabled are required");
}
const conversationId = sidFromKey(p.sessionKey);
const existingEntry = await loadAnyForm(p.sessionKey);
const existingRollback = existingEntry?.secafsRollback ?? {};
if (p.enabled) {
const r = await opts.rpc.snapshotEnable({ conversationId });
await patchAllForms(p.sessionKey, {
secafsRollback: { ...existingRollback, enabled: r.enabled },
});
return { enabled: r.enabled };
}
const r = await opts.rpc.snapshotDisable({ conversationId });
await patchAllForms(p.sessionKey, {
secafsRollback: { enabled: false },
});
return {
enabled: false,
purgedSnapshots: r.purgedSnapshots,
purgedUndoRows: r.purgedUndoRows,
};
});
opts.registerGatewayMethod("secafs.rollback.list", async (raw: unknown) => {
const p = raw as { sessionKey?: string } | undefined;
if (!p?.sessionKey) {
throw new Error("sessionKey is required");
}
const conversationId = sidFromKey(p.sessionKey);
const list = await opts.rpc.snapshotList({ conversationId });
const entry = await loadAnyForm(p.sessionKey);
const enabled = entry?.secafsRollback?.enabled === true;
return {
enabled,
snapshots: list.snapshots.map((s) => ({
snapId: s.snapId,
messageId: s.label,
committedAt: s.committedAt,
})),
};
});
opts.registerGatewayMethod("secafs.rollback.restore", async (raw: unknown) => {
const p = raw as { sessionKey?: string; snapId?: number } | undefined;
if (!p?.sessionKey || typeof p.snapId !== "number") {
throw new Error("sessionKey and snapId are required");
}
return await opts.handleRestore({ sessionKey: p.sessionKey, snapId: p.snapId });
});
if (opts.snapshotNow) {
const snapshotNow = opts.snapshotNow;
opts.registerGatewayMethod("secafs.rollback.snapshot", async (raw: unknown) => {
const p = raw as { sessionKey?: string } | undefined;
if (!p?.sessionKey) {
throw new Error("sessionKey is required");
}
return await snapshotNow({ sessionKey: p.sessionKey });
});
}
}
opts.registerGatewayMethod("secafs.session.destroy", async (raw: unknown) => {
const params = raw as { sessionKey: string };
const sid = sidFromKey(params.sessionKey);
try {
await opts.rpc.unmount({ conversationId: sid });
} catch {
}
await opts.rpc.destroy({ conversationId: sid });
if (opts.deleteSessionArtifacts) {
try {
await opts.deleteSessionArtifacts(sid);
} catch (e) {
opts.logger?.warn?.(
`[secafs-chat] destroy: transcript cleanup failed for ${sid}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
const mk = opts.mainKey ?? "main";
const aid = opts.defaultAgentId ?? "main";
const bareKey = `${mk}:${sid}`;
const canonicalKey = `agent:${aid}:${mk}:${sid}`;
const keys = [...new Set([bareKey, canonicalKey, params.sessionKey])];
for (const key of keys) {
try {
await opts.workspace.clear(key);
} catch {
}
}
for (const key of keys) {
try {
await opts.sessions.delete(key);
} catch {
}
}
return { destroyed: true };
});
}