1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5
6import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9
10export interface WorkflowFileIdentity {
11 readonly dev: number;
12 readonly ino: number;
13}
14
15function workflowFilesRoot(home: string, configDir?: string): string {
16 return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects");
17}
18
19export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")(
20 function* (input: {
21 readonly path: string;
22 readonly extension: string;
23 readonly byteCap: number;
24 readonly tail?: boolean;
25 readonly configDir?: string;
26 /** Reads complete lines from a byte offset; resets to zero after shrinkage or inode changes. */
27 readonly fromOffset?: {
28 readonly offset: number;
29 readonly identity: WorkflowFileIdentity | null;
30 };
31 }) {
32 if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) {
33 return yield* new OrchestrationWorkflowFileError({
34 reason: "invalid-path",
35 path: input.path,
36 });
37 }
38
39 const home = yield* HostProcess.HomeDirectory;
40 const root = yield* Effect.tryPromise({
41 try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)),
42 catch: (cause) =>
43 new OrchestrationWorkflowFileError({
44 reason: "root-unavailable",
45 path: input.path,
46 cause,
47 }),
48 });
49
50 // Resolve the file itself; contained directories can still hold escaping symlinks.
51 const resolved = yield* Effect.tryPromise({
52 try: () => NodeFSP.realpath(input.path),
53 catch: (cause) =>
54 new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }),
55 });
56
57 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
58 return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved });
59 }
60 if (NodePath.extname(resolved) !== input.extension) {
61 return yield* new OrchestrationWorkflowFileError({
62 reason: "wrong-extension",
63 path: resolved,
64 });
65 }
66
67 // Inode checks catch leaf swaps, not intermediate directory swaps; the 0700 ~/.claude root restricts those to our uid.
68 const read = yield* Effect.tryPromise({
69 try: async () => {
70 const handle = await NodeFSP.open(
71 resolved,
72 NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
73 );
74 try {
75 const stat = await handle.stat();
76 if (!stat.isFile()) {
77 return { failure: "not-regular-file" as const };
78 }
79 const pathStat = await NodeFSP.lstat(resolved);
80 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
81 return { failure: "changed-during-read" as const };
82 }
83 const identity = { dev: stat.dev, ino: stat.ino };
84 if (input.fromOffset !== undefined) {
85 const previous = input.fromOffset.identity;
86 const start =
87 previous !== null &&
88 previous.dev === identity.dev &&
89 previous.ino === identity.ino &&
90 input.fromOffset.offset <= stat.size
91 ? input.fromOffset.offset
92 : 0;
93 const buffer = Buffer.alloc(Math.min(stat.size - start, input.byteCap));
94 const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
95 const read = buffer.subarray(0, bytesRead);
96 // Defer partial lines; skip single over-cap lines so the cursor can advance.
97 const lineEnd = read.lastIndexOf(0x0a) + 1;
98 const consumed = lineEnd === 0 && bytesRead === input.byteCap ? bytesRead : lineEnd;
99 return {
100 contents: read.subarray(0, lineEnd).toString("utf8"),
101 // Only the cap signals truncation; a half-written last line does not.
102 truncated: stat.size - start > input.byteCap,
103 startOffset: start,
104 endOffset: start + consumed,
105 identity,
106 };
107 }
108 const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0)));
109 const truncated = stat.size > buffer.length;
110 const offset = input.tail ? stat.size - buffer.length : 0;
111 const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
112 return {
113 contents: buffer.subarray(0, bytesRead).toString("utf8"),
114 truncated,
115 startOffset: offset,
116 endOffset: offset + bytesRead,
117 identity,
118 };
119 } finally {
120 await handle.close();
121 }
122 },
123 catch: (cause) =>
124 new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }),
125 });
126 if ("failure" in read) {
127 return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved });
128 }
129
130 return {
131 path: resolved,
132 contents: read.contents,
133 truncated: read.truncated,
134 startOffset: read.startOffset,
135 endOffset: read.endOffset,
136 identity: read.identity,
137 };
138 },
139);