319 lines · 11.4 KB · TypeScript
RawDownload
// @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<WorkflowReadScriptResult, WorkflowInspectionError>;    readonly readJournal: (      input: WorkflowReadJournalInput,    ) => Effect.Effect<WorkflowReadJournalResult, WorkflowInspectionError>;    readonly readAgentTranscript: (      input: WorkflowReadAgentTranscriptInput,    ) => Effect.Effect<WorkflowReadAgentTranscriptResult, WorkflowInspectionError>;  }>()("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<string, MutableJournalEntry>();      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-<id>.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());