/** * Admin-conversation JSONL purge — Task 237. * * Shared primitive that fires the claude-session-manager's * `DELETE /:sessionId` per attached JSONL. Pure JSONL-only: it does not * touch the graph. Callers (the `cascadeAdminConversationDelete` engine * and `memory-empty-trash`'s defense-in-depth `onEmpty`) own the graph * side and decide whether to proceed after the JSONL outcome lands. * * Contract: * - manager 204 (purged) or 404 (manager never knew the JSONL) both count * as "transcript no longer exists" and proceed to the next sessionId. * - manager 409 is the structural `pty-still-alive` stop check; the loop * halts and the caller surfaces the live sessionId. * - any other status or transport failure halts the loop with * `jsonl-purge-failed`, naming the failing sessionId and the ids already * purged at the failure point. * * Log lines mirror the Task 217 cascade so a single grep * (`[admin-conversation-delete]`) covers both call sites. */ export interface PurgeAdminConversationJsonlsArgs { /** Used only for log id8; not for routing. */ sessionId: string; /** Used only for log line context; the helper does not enforce isolation * (the caller has already account-scoped the candidate). */ accountId: string; /** Loopback manager base URL — `http://127.0.0.1:${CLAUDE_SESSION_MANAGER_PORT}`. */ managerBase: string; /** Pre-resolved sessionIds to purge. Caller chooses the lookup strategy * (`getConversationClaudeSessionIds` for live conversations, * elementId-based walk for trashed ones). */ claudeSessionIds: string[]; fetchImpl?: typeof fetch; logger?: (line: string) => void; } export type PurgeAdminConversationJsonlsOutcome = | { ok: true; claudeSessionIdsPurged: string[] } | { ok: false; reason: "pty-still-alive"; claudeSessionId: string; claudeSessionIdsPurged: string[]; } | { ok: false; reason: "jsonl-purge-failed"; claudeSessionId: string; claudeSessionIdsPurged: string[]; status: number; message: string; }; export async function purgeAdminConversationJsonls( args: PurgeAdminConversationJsonlsArgs, ): Promise { const fetchImpl = args.fetchImpl ?? fetch; const log = args.logger ?? ((line: string) => console.log(line)); const id8 = args.sessionId.slice(0, 8); const purged: string[] = []; for (const sessionId of args.claudeSessionIds) { const url = `${args.managerBase}/${encodeURIComponent(sessionId)}`; let res: Response; try { res = await fetchImpl(url, { method: "DELETE" }); } catch (err) { const message = err instanceof Error ? err.message : String(err); log( `[admin-conversation-delete] sessionId=${id8} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`, ); return { ok: false, reason: "jsonl-purge-failed", claudeSessionId: sessionId, claudeSessionIdsPurged: purged, status: 0, message, }; } if (res.status === 204 || res.status === 404) { purged.push(sessionId); continue; } if (res.status === 409) { log( `[admin-conversation-delete] sessionId=${id8} reason=pty-still-alive claudeSessionId=${sessionId.slice(0, 8)}`, ); return { ok: false, reason: "pty-still-alive", claudeSessionId: sessionId, claudeSessionIdsPurged: purged, }; } let message = `manager returned ${res.status}`; try { const body = (await res.json()) as { error?: string; detail?: string }; if (typeof body.detail === "string") message = body.detail; else if (typeof body.error === "string") message = body.error; } catch { // body isn't JSON — keep status-coded message } log( `[admin-conversation-delete] sessionId=${id8} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`, ); return { ok: false, reason: "jsonl-purge-failed", claudeSessionId: sessionId, claudeSessionIdsPurged: purged, status: res.status, message, }; } return { ok: true, claudeSessionIdsPurged: purged }; }