1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
4 *
5 * Owns workspace-root-relative file read/write operations and their associated
6 * safety checks and cache invalidation hooks. Reads also accept absolute host
7 * paths so clients can show files an agent left outside the workspace; writes
8 * never leave the root.
9 *
10 * @module WorkspaceFileSystem
11 */
12import * as NodeFS from "node:fs";
13import * as NodeFSP from "node:fs/promises";
14
15import type {
16 FilesystemEntryMetadata,
17 FilesystemGetMetadataInput,
18 FilesystemGetMetadataResult,
19 ProjectReadFileInput,
20 ProjectWatchInput,
21 ProjectReadFileResult,
22 ProjectWriteFileInput,
23 ProjectWriteFileResult,
24} from "@t3tools/contracts";
25import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
26import * as HostProcess from "@t3tools/shared/HostProcess";
27import * as Context from "effect/Context";
28import * as Clock from "effect/Clock";
29import * as Cause from "effect/Cause";
30import * as Effect from "effect/Effect";
31import * as FileSystem from "effect/FileSystem";
32import * as Layer from "effect/Layer";
33import * as Path from "effect/Path";
34import * as Schema from "effect/Schema";
35import * as Stream from "effect/Stream";
36import * as Queue from "effect/Queue";
37
38import * as WorkspaceEntries from "./WorkspaceEntries.ts";
39import * as WorkspacePaths from "./WorkspacePaths.ts";
40import { fileHeaderMimeType } from "./fileHeaderMimeType.ts";
41
42const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
43
44export class WorkspaceFileSystemOperationError extends Schema.TaggedError<WorkspaceFileSystemOperationError>()(
45 "WorkspaceFileSystemOperationError",
46 {
47 workspaceRoot: Schema.String,
48 relativePath: Schema.String,
49 resolvedPath: Schema.String,
50 operationPath: Schema.String,
51 operation: Schema.Literals([
52 "realpath-workspace-root",
53 "realpath-target",
54 "open",
55 "stat",
56 "read",
57 "close",
58 "make-directory",
59 "write-file",
60 "watch",
61 ]),
62 cause: Schema.Defect(),
63 },
64) {
65 override get message(): string {
66 return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;
67 }
68}
69
70export class WorkspaceFilePathEscapeError extends Schema.TaggedError<WorkspaceFilePathEscapeError>()(
71 "WorkspaceFilePathEscapeError",
72 {
73 workspaceRoot: Schema.String,
74 relativePath: Schema.String,
75 resolvedWorkspaceRoot: Schema.String,
76 resolvedPath: Schema.String,
77 },
78) {
79 override get message(): string {
80 return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`;
81 }
82}
83
84export class WorkspacePathNotFileError extends Schema.TaggedError<WorkspacePathNotFileError>()(
85 "WorkspacePathNotFileError",
86 {
87 workspaceRoot: Schema.String,
88 relativePath: Schema.String,
89 resolvedPath: Schema.String,
90 },
91) {
92 override get message(): string {
93 return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`;
94 }
95}
96
97export class WorkspaceBinaryFileError extends Schema.TaggedError<WorkspaceBinaryFileError>()(
98 "WorkspaceBinaryFileError",
99 {
100 workspaceRoot: Schema.String,
101 relativePath: Schema.String,
102 resolvedPath: Schema.String,
103 },
104) {
105 override get message(): string {
106 return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`;
107 }
108}
109
110export const WorkspaceFileSystemError = Schema.Union([
111 WorkspaceFileSystemOperationError,
112 WorkspaceFilePathEscapeError,
113 WorkspacePathNotFileError,
114 WorkspaceBinaryFileError,
115]);
116export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
117
118/** Service tag for workspace file operations. */
119export class WorkspaceFileSystem extends Context.Service<
120 WorkspaceFileSystem,
121 {
122 readonly watch: (
123 input: ProjectWatchInput,
124 ) => Stream.Stream<
125 number,
126 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
127 >;
128
129 readonly getMetadata: (
130 input: FilesystemGetMetadataInput,
131 ) => Effect.Effect<FilesystemGetMetadataResult>;
132
133 /**
134 * Read a UTF-8 text file relative to the workspace root, or any host file by
135 * absolute path.
136 */
137 readonly readFile: (
138 input: ProjectReadFileInput,
139 ) => Effect.Effect<
140 ProjectReadFileResult,
141 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
142 >;
143 /**
144 * Write a file relative to the workspace root.
145 *
146 * Creates parent directories as needed and rejects paths that escape the
147 * workspace root.
148 */
149 readonly writeFile: (
150 input: ProjectWriteFileInput,
151 ) => Effect.Effect<
152 ProjectWriteFileResult,
153 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
154 >;
155 }
156>()("t3/workspace/WorkspaceFileSystem") {}
157
158/** @public Service construction is part of the canonical Effect module API. */
159export const make = Effect.gen(function* () {
160 const fileSystem = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
163 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
164
165 const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) {
166 const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory);
167 if (!path.isAbsolute(expandedPath)) return null;
168 const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option);
169 if (stat._tag === "None") return null;
170 if (stat.value.type === "Directory") return { kind: "directory" } as const;
171 if (stat.value.type !== "File") return { kind: "other" } as const;
172 let mimeType: string | undefined;
173 // Named extensions already choose the icon without a content read. For an
174 // extensionless regular file, read at most 512 bytes, never the whole file.
175 if (path.extname(expandedPath) === "" && stat.value.size > 0) {
176 mimeType = yield* Effect.tryPromise({
177 try: async () => {
178 const handle = await NodeFSP.open(
179 expandedPath,
180 NodeFS.constants.O_RDONLY | NodeFS.constants.O_NONBLOCK,
181 );
182 try {
183 if (!(await handle.stat()).isFile()) return undefined;
184 const buffer = Buffer.alloc(512);
185 const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
186 return fileHeaderMimeType(buffer.subarray(0, bytesRead));
187 } finally {
188 await handle.close();
189 }
190 },
191 catch: () => undefined,
192 }).pipe(Effect.catch(() => Effect.succeed(undefined)));
193 }
194 return {
195 kind: "file",
196 byteLength: Number(stat.value.size),
197 ...(mimeType === undefined ? {} : { mimeType }),
198 } satisfies FilesystemEntryMetadata;
199 });
200
201 const getMetadata: WorkspaceFileSystem["Service"]["getMetadata"] = Effect.fn(
202 "WorkspaceFileSystem.getMetadata",
203 )(function* (input) {
204 const paths = [...new Set(input.paths)];
205 const entries = yield* Effect.forEach(paths, metadataForPath, { concurrency: 8 });
206 const byPath = new Map(paths.map((value, index) => [value, entries[index] ?? null]));
207 return { entries: input.paths.map((value) => byPath.get(value) ?? null) };
208 });
209
210 /**
211 * Resolves the file a read targets. Workspace-relative paths must stay inside the
212 * root, symlinks included. An absolute path reads a host file in place, such as a
213 * report an agent wrote to a temp directory; it gets no root check.
214 */
215 const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* (
216 input: ProjectReadFileInput,
217 ) {
218 const requestedPath = input.relativePath.trim();
219 if (path.isAbsolute(requestedPath)) {
220 const realTargetPath = yield* Effect.tryPromise({
221 try: () => NodeFSP.realpath(requestedPath),
222 catch: (cause) =>
223 new WorkspaceFileSystemOperationError({
224 workspaceRoot: input.cwd,
225 relativePath: input.relativePath,
226 resolvedPath: requestedPath,
227 operationPath: requestedPath,
228 operation: "realpath-target",
229 cause,
230 }),
231 });
232 return { relativePath: requestedPath, realTargetPath };
233 }
234
235 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
236 workspaceRoot: input.cwd,
237 relativePath: input.relativePath,
238 });
239
240 const realWorkspaceRoot = yield* Effect.tryPromise({
241 try: () => NodeFSP.realpath(input.cwd),
242 catch: (cause) =>
243 new WorkspaceFileSystemOperationError({
244 workspaceRoot: input.cwd,
245 relativePath: input.relativePath,
246 resolvedPath: target.absolutePath,
247 operationPath: input.cwd,
248 operation: "realpath-workspace-root",
249 cause,
250 }),
251 });
252 const realTargetPath = yield* Effect.tryPromise({
253 try: () => NodeFSP.realpath(target.absolutePath),
254 catch: (cause) =>
255 new WorkspaceFileSystemOperationError({
256 workspaceRoot: input.cwd,
257 relativePath: input.relativePath,
258 resolvedPath: target.absolutePath,
259 operationPath: target.absolutePath,
260 operation: "realpath-target",
261 cause,
262 }),
263 });
264 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
265 if (
266 relativeRealPath.startsWith(`..${path.sep}`) ||
267 relativeRealPath === ".." ||
268 path.isAbsolute(relativeRealPath)
269 ) {
270 return yield* new WorkspaceFilePathEscapeError({
271 workspaceRoot: input.cwd,
272 relativePath: input.relativePath,
273 resolvedWorkspaceRoot: realWorkspaceRoot,
274 resolvedPath: realTargetPath,
275 });
276 }
277 return { relativePath: target.relativePath, realTargetPath };
278 });
279
280 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
281 "WorkspaceFileSystem.readFile",
282 )(function* (input) {
283 const target = yield* resolveReadTarget(input);
284 const realTargetPath = target.realTargetPath;
285
286 return yield* Effect.acquireUseRelease(
287 Effect.tryPromise({
288 // Non-blocking so a FIFO cannot hang the open; the stat below rejects
289 // it. Regular files ignore the flag. Windows lacks it.
290 try: () =>
291 NodeFSP.open(
292 realTargetPath,
293 NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
294 ),
295 catch: (cause) =>
296 new WorkspaceFileSystemOperationError({
297 workspaceRoot: input.cwd,
298 relativePath: input.relativePath,
299 resolvedPath: realTargetPath,
300 operationPath: realTargetPath,
301 operation: "open",
302 cause,
303 }),
304 }),
305 (handle) =>
306 Effect.gen(function* () {
307 const stat = yield* Effect.tryPromise({
308 try: () => handle.stat(),
309 catch: (cause) =>
310 new WorkspaceFileSystemOperationError({
311 workspaceRoot: input.cwd,
312 relativePath: input.relativePath,
313 resolvedPath: realTargetPath,
314 operationPath: realTargetPath,
315 operation: "stat",
316 cause,
317 }),
318 });
319 if (!stat.isFile()) {
320 return yield* new WorkspacePathNotFileError({
321 workspaceRoot: input.cwd,
322 relativePath: input.relativePath,
323 resolvedPath: realTargetPath,
324 });
325 }
326
327 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
328 const buffer = Buffer.alloc(bytesToRead);
329 const { bytesRead } = yield* Effect.tryPromise({
330 try: () => handle.read(buffer, 0, bytesToRead, 0),
331 catch: (cause) =>
332 new WorkspaceFileSystemOperationError({
333 workspaceRoot: input.cwd,
334 relativePath: input.relativePath,
335 resolvedPath: realTargetPath,
336 operationPath: realTargetPath,
337 operation: "read",
338 cause,
339 }),
340 });
341 const fileBytes = buffer.subarray(0, bytesRead);
342 if (fileBytes.includes(0)) {
343 return yield* new WorkspaceBinaryFileError({
344 workspaceRoot: input.cwd,
345 relativePath: input.relativePath,
346 resolvedPath: realTargetPath,
347 });
348 }
349
350 return {
351 relativePath: target.relativePath,
352 contents: new TextDecoder("utf-8").decode(fileBytes),
353 byteLength: stat.size,
354 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
355 };
356 }),
357 (handle) =>
358 Effect.tryPromise({
359 try: () => handle.close(),
360 catch: (cause) =>
361 new WorkspaceFileSystemOperationError({
362 workspaceRoot: input.cwd,
363 relativePath: input.relativePath,
364 resolvedPath: realTargetPath,
365 operationPath: realTargetPath,
366 operation: "close",
367 cause,
368 }),
369 }),
370 );
371 });
372
373 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
374 "WorkspaceFileSystem.writeFile",
375 )(function* (input) {
376 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
377 workspaceRoot: input.cwd,
378 relativePath: input.relativePath,
379 });
380
381 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
382 Effect.mapError(
383 (cause) =>
384 new WorkspaceFileSystemOperationError({
385 workspaceRoot: input.cwd,
386 relativePath: input.relativePath,
387 resolvedPath: target.absolutePath,
388 operationPath: path.dirname(target.absolutePath),
389 operation: "make-directory",
390 cause,
391 }),
392 ),
393 );
394 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
395 Effect.mapError(
396 (cause) =>
397 new WorkspaceFileSystemOperationError({
398 workspaceRoot: input.cwd,
399 relativePath: input.relativePath,
400 resolvedPath: target.absolutePath,
401 operationPath: target.absolutePath,
402 operation: "write-file",
403 cause,
404 }),
405 ),
406 );
407 yield* workspaceEntries.refresh(input.cwd);
408 return { relativePath: target.relativePath };
409 });
410
411 const watch: WorkspaceFileSystem["Service"]["watch"] = (input) =>
412 Stream.unwrap(
413 Effect.gen(function* () {
414 const requestedPath =
415 input.relativePath === "."
416 ? path.resolve(input.cwd)
417 : path.isAbsolute(input.relativePath)
418 ? input.relativePath
419 : (yield* workspacePaths.resolveRelativePathWithinRoot({
420 workspaceRoot: input.cwd,
421 relativePath: input.relativePath,
422 })).absolutePath;
423 const realRoot = path.isAbsolute(input.relativePath)
424 ? null
425 : (yield* resolveReadTarget({ cwd: input.cwd, relativePath: path.resolve(input.cwd) }))
426 .realTargetPath;
427 yield* resolveReadTarget({
428 ...input,
429 relativePath: input.relativePath === "." ? requestedPath : input.relativePath,
430 }).pipe(
431 Effect.catchTags({
432 WorkspaceFileSystemOperationError: (error) =>
433 error.operation === "realpath-target" &&
434 typeof error.cause === "object" &&
435 error.cause !== null &&
436 "code" in error.cause &&
437 error.cause.code === "ENOENT"
438 ? Effect.void
439 : Effect.fail(error),
440 }),
441 );
442 const startedAt = yield* Clock.currentTimeMillis;
443 return Stream.callback<number, WorkspaceFileSystemOperationError>(
444 (mailbox) => {
445 const watchError = (cause: unknown) =>
446 new WorkspaceFileSystemOperationError({
447 workspaceRoot: input.cwd,
448 relativePath: input.relativePath,
449 resolvedPath: requestedPath,
450 operationPath: requestedPath,
451 operation: "watch",
452 cause,
453 });
454 return Effect.acquireRelease(
455 Effect.try({
456 try: () => {
457 let revision = startedAt;
458 let generation = 0;
459 let watchers: NodeFS.FSWatcher[] = [];
460 const close = () => {
461 generation++;
462 for (const watcher of watchers) watcher.close();
463 watchers = [];
464 };
465 const attach = () => {
466 const currentGeneration = ++generation;
467 const directories = new Map<string, Set<string> | null>();
468 const addParents = (target: string, root: string) => {
469 for (let child = target; child !== root;) {
470 const parent = path.dirname(child);
471 if (parent === child) break;
472 const names = directories.get(parent);
473 if (names !== null)
474 directories.set(parent, (names ?? new Set()).add(path.basename(child)));
475 child = parent;
476 }
477 };
478 addParents(requestedPath, path.dirname(path.resolve(input.cwd)));
479 try {
480 const realTarget = NodeFS.realpathSync(requestedPath);
481 const relative =
482 realRoot === null ? null : path.relative(realRoot, realTarget);
483 const insideRoot =
484 relative === null ||
485 !(
486 relative === ".." ||
487 relative.startsWith(`..${path.sep}`) ||
488 path.isAbsolute(relative)
489 );
490 if (insideRoot) {
491 addParents(
492 realTarget,
493 realRoot === null ? path.parse(realTarget).root : path.dirname(realRoot),
494 );
495 if (input.directory && NodeFS.statSync(requestedPath).isDirectory())
496 directories.set(requestedPath, null);
497 }
498 } catch {
499 // Ancestor watches remain attached while the target is absent.
500 }
501 const next: NodeFS.FSWatcher[] = [];
502 try {
503 for (const [directory, names] of directories) {
504 if (!NodeFS.existsSync(directory)) continue;
505 const watcher = NodeFS.watch(directory, (_event, filename) => {
506 if (generation !== currentGeneration) return;
507 if (
508 filename !== null &&
509 names !== null &&
510 !names.has(filename.toString())
511 )
512 return;
513 try {
514 attach();
515 Queue.offerUnsafe(mailbox, ++revision);
516 } catch (cause) {
517 Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause)));
518 }
519 });
520 watcher.on("error", (cause) => {
521 if (generation === currentGeneration)
522 Queue.failCauseUnsafe(mailbox, Cause.fail(watchError(cause)));
523 });
524 next.push(watcher);
525 }
526 } catch (cause) {
527 for (const watcher of next) watcher.close();
528 throw cause;
529 }
530 for (const watcher of watchers) watcher.close();
531 watchers = next;
532 };
533 try {
534 attach();
535 Queue.offerUnsafe(mailbox, revision);
536 return close;
537 } catch (cause) {
538 close();
539 throw cause;
540 }
541 },
542 catch: watchError,
543 }),
544 (close) => Effect.sync(close),
545 );
546 },
547 { bufferSize: 1, strategy: "sliding" },
548 ).pipe(Stream.debounce("100 millis"));
549 }),
550 );
551
552 return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile, watch });
553});
554
555export const layer = Layer.effect(WorkspaceFileSystem, make);