// @effect-diagnostics nodeBuiltinImport:off - realpath containment must use Node's fs/path directly. import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { WorkflowInspectionError, type WorkflowReadAgentTranscriptInput, type WorkflowReadAgentTranscriptResult, type WorkflowReadJournalInput, type WorkflowReadJournalResult, type WorkflowReadScriptInput, type WorkflowReadScriptResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; /** * Read-only inspection of Claude Agent SDK workflow-run artifacts on local * disk. Clients echo server-local paths back over RPC, so every path is * validated structurally (absolute + realpath-contained inside the projects * root) before any disk access — the service must never become an * arbitrary-file-read oracle. */ /** `readScript`: clip source text past this many characters. */ const SCRIPT_MAX_CHARS = 512 * 1024; /** `readJournal`: clip each serialized result past this many characters. */ const JOURNAL_RESULT_MAX_CHARS = 32 * 1024; /** `readJournal`: cap the number of distinct agents reported. */ const JOURNAL_MAX_ENTRIES = 512; /** `readAgentTranscript`: cap the number of lines returned per page. */ const TRANSCRIPT_MAX_LINES = 400; /** `readAgentTranscript`: stop a page once accumulated chars exceed this. */ const TRANSCRIPT_MAX_CHARS = 768 * 1024; /** Only these agent id shapes are accepted before touching the filesystem. */ const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/; /** Mutable while parsing the journal; frozen into the readonly contract shape. */ interface MutableJournalEntry { agentId: string; hasResult: boolean; resultJson?: string; resultTruncated?: boolean; } /** Parse one JSONL line defensively; unparseable lines return `undefined`. */ const parseJsonLine = (text: string): unknown => { try { return JSON.parse(text) as unknown; } catch { return undefined; } }; const isEnoent = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "code" in cause && (cause as { code?: unknown }).code === "ENOENT"; const isNotFoundPlatformError = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "reason" in cause && typeof (cause as { reason?: unknown }).reason === "object" && (cause as { reason: { _tag?: unknown } }).reason !== null && (cause as { reason: { _tag?: unknown } }).reason._tag === "NotFound"; export class WorkflowInspectionService extends Context.Service< WorkflowInspectionService, { readonly readScript: ( input: WorkflowReadScriptInput, ) => Effect.Effect; readonly readJournal: ( input: WorkflowReadJournalInput, ) => Effect.Effect; readonly readAgentTranscript: ( input: WorkflowReadAgentTranscriptInput, ) => Effect.Effect; } >()("t3/workflow/WorkflowInspectionService") {} export const make = (options?: { readonly projectsRoot?: string }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const projectsRoot = options?.projectsRoot ?? NodePath.join(NodeOS.homedir(), ".claude", "projects"); /** * Resolve the real path of `target` and prove it is contained within the * real projects root using a path-segment-safe prefix comparison. ENOENT * during either realpath maps to `not-found`; escape maps to * `invalid-path`. */ const resolveContained = Effect.fn("WorkflowInspectionService.resolveContained")(function* ( operation: string, target: string, ) { if (!NodePath.isAbsolute(target)) { return yield* new WorkflowInspectionError({ operation, reason: "invalid-path", detail: "Path must be absolute.", }); } const realRoot = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(projectsRoot), catch: (cause) => new WorkflowInspectionError({ operation, reason: isEnoent(cause) ? "not-found" : "read-failed", detail: "Failed to resolve the workflow projects root.", cause, }), }); const realTarget = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(target), catch: (cause) => new WorkflowInspectionError({ operation, reason: isEnoent(cause) ? "not-found" : "read-failed", detail: "Failed to resolve the requested path.", cause, }), }); if (realTarget !== realRoot && !realTarget.startsWith(realRoot + NodePath.sep)) { return yield* new WorkflowInspectionError({ operation, reason: "invalid-path", detail: "Path escapes the workflow projects root.", }); } return realTarget; }); const readScript = Effect.fn("WorkflowInspectionService.readScript")(function* ( input: WorkflowReadScriptInput, ) { const operation = "WorkflowInspectionService.readScript"; if (!input.scriptPath.endsWith(".js") && !input.scriptPath.endsWith(".mjs")) { return yield* new WorkflowInspectionError({ operation, reason: "invalid-path", detail: "Script path must end with .js or .mjs.", }); } const realPath = yield* resolveContained(operation, input.scriptPath); const source = yield* fs.readFileString(realPath).pipe( Effect.mapError( (cause) => new WorkflowInspectionError({ operation, reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", detail: "Failed to read the workflow script.", cause, }), ), ); const truncated = source.length > SCRIPT_MAX_CHARS; return { source: truncated ? source.slice(0, SCRIPT_MAX_CHARS) : source, truncated, } satisfies WorkflowReadScriptResult; }); const readJournal = Effect.fn("WorkflowInspectionService.readJournal")(function* ( input: WorkflowReadJournalInput, ) { const operation = "WorkflowInspectionService.readJournal"; const realDir = yield* resolveContained(operation, input.transcriptDir); // Re-contain the joined leaf: a symlink named journal.jsonl inside a // valid directory must not escape the projects root. const journalPath = yield* resolveContained( operation, NodePath.join(realDir, "journal.jsonl"), ); const raw = yield* fs.readFileString(journalPath).pipe( Effect.mapError( (cause) => new WorkflowInspectionError({ operation, reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", detail: "Failed to read the workflow journal.", cause, }), ), ); // Preserve first-seen agent order via an insertion-ordered Map. const entries = new Map(); let truncated = false; const ensureEntry = (agentId: string): MutableJournalEntry | undefined => { const existing = entries.get(agentId); if (existing !== undefined) return existing; if (entries.size >= JOURNAL_MAX_ENTRIES) { truncated = true; return undefined; } const created: MutableJournalEntry = { agentId, hasResult: false }; entries.set(agentId, created); return created; }; for (const line of raw.split("\n")) { const text = line.trim(); if (text.length === 0) continue; const record = parseJsonLine(text); if (record === undefined) continue; if (typeof record !== "object" || record === null) continue; const parsed = record as { type?: unknown; agentId?: unknown; result?: unknown; }; if (typeof parsed.agentId !== "string" || parsed.agentId.length === 0) continue; if (parsed.type === "started") { ensureEntry(parsed.agentId); continue; } if (parsed.type === "result") { const entry = ensureEntry(parsed.agentId); if (entry === undefined) continue; entry.hasResult = true; // @effect-diagnostics-next-line preferSchemaOverJson:off - result is arbitrary JSON re-serialized verbatim. const serialized = JSON.stringify(parsed.result); if (serialized !== undefined) { const resultTruncated = serialized.length > JOURNAL_RESULT_MAX_CHARS; entry.resultJson = resultTruncated ? serialized.slice(0, JOURNAL_RESULT_MAX_CHARS) : serialized; if (resultTruncated) entry.resultTruncated = true; } } } return { entries: Array.from(entries.values()), truncated, } satisfies WorkflowReadJournalResult; }); const readAgentTranscript = Effect.fn("WorkflowInspectionService.readAgentTranscript")( function* (input: WorkflowReadAgentTranscriptInput) { const operation = "WorkflowInspectionService.readAgentTranscript"; if (!AGENT_ID_PATTERN.test(input.agentId)) { return yield* new WorkflowInspectionError({ operation, reason: "invalid-path", detail: "Agent id contains unsupported characters.", }); } const realDir = yield* resolveContained(operation, input.transcriptDir); // Re-contain the joined leaf: a symlink named agent-.jsonl inside // a valid directory must not escape the projects root. const transcriptPath = yield* resolveContained( operation, NodePath.join(realDir, `agent-${input.agentId}.jsonl`), ); // v1 reads the whole file per page; acceptable for current transcript // sizes. Revisit with a streaming/seek reader if transcripts grow large. const raw = yield* fs.readFileString(transcriptPath).pipe( Effect.mapError( (cause) => new WorkflowInspectionError({ operation, reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", detail: "Failed to read the agent transcript.", cause, }), ), ); const allLines = raw.split("\n"); if (allLines.length > 0 && allLines[allLines.length - 1] === "") { allLines.pop(); } const total = allLines.length; const afterLine = Math.max(0, input.afterLine ?? 0); const lines: string[] = []; let accumulated = 0; for (let index = afterLine; index < total && lines.length < TRANSCRIPT_MAX_LINES; index++) { const current = allLines[index] ?? ""; lines.push(current); accumulated += current.length; if (accumulated > TRANSCRIPT_MAX_CHARS) break; } const nextLine = afterLine + lines.length; return { lines, nextLine, complete: nextLine >= total, } satisfies WorkflowReadAgentTranscriptResult; }, ); return WorkflowInspectionService.of({ readScript, readJournal, readAgentTranscript, }); }); export const layer = Layer.effect(WorkflowInspectionService, make());