1import { formatPlanPreview } from "./Plan.ts";
2/**
3 * Persistent run log: every test's pass/failure followed by its captured
4 * output, appended to `.alchemy/log/test.log` as the run progresses —
5 * color-free so agents and tooling can read it directly.
6 */
7import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Queue from "effect/Queue";
14
15import type { LogEntry } from "./Model.ts";
16import type { TestEvent } from "./Reporter.ts";
17
18const formatDuration = (ms: number): string =>
19 ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
20
21const formatLogs = (logs: ReadonlyArray<LogEntry>): string =>
22 logs.map((log) => log.message).join("\n");
23
24const GLYPH = {
25 pass: "PASS",
26 fail: "FAIL",
27 skip: "SKIP",
28 todo: "TODO",
29} as const;
30
31/** Render an event as a log chunk; `undefined` = nothing to write. */
32export const formatEvent = (event: TestEvent): string | undefined => {
33 switch (event._tag) {
34 case "PlanPreview":
35 return `${formatPlanPreview(event.phases)}\n`;
36 case "PlanPhaseStart":
37 return `Plan phase ${event.phase}/${event.phases}: ${event.tests} tests\n`;
38 case "RunStart":
39 return `running ${event.tests.length} tests from ${event.files} files (${new Date().toISOString()})\n\n`;
40 case "TestEnd": {
41 const title = `${event.test.file} > ${event.test.titlePath.join(" > ")}`;
42 const retries =
43 event.result.retries > 0 ? ` [retried x${event.result.retries}]` : "";
44 const lines = [
45 `${GLYPH[event.result.status]} ${title} (${formatDuration(event.result.durationMs)})${retries}`,
46 ];
47 if (event.result.error !== undefined) {
48 lines.push(event.result.error);
49 }
50 if (event.result.logs.length > 0) {
51 lines.push("--- captured output ---");
52 lines.push(formatLogs(event.result.logs));
53 lines.push("--- end output ---");
54 }
55 // One line per plain result; a blank separator only after multi-line
56 // entries (error/output blocks) so they don't run into the next entry.
57 return lines.length === 1 ? `${lines[0]}\n` : `${lines.join("\n")}\n\n`;
58 }
59 case "FileEnd": {
60 const lines: Array<string> = [];
61 if (event.error !== undefined) {
62 lines.push(`FAIL ${event.file} failed to run`, event.error);
63 }
64 if (event.logs.length > 0) {
65 lines.push(`--- file hook output (${event.file}) ---`);
66 lines.push(formatLogs(event.logs));
67 lines.push("--- end output ---");
68 }
69 return lines.length === 0 ? undefined : `${lines.join("\n")}\n\n`;
70 }
71 case "RunEnd": {
72 const s = event.summary;
73 return (
74 `Tests: ${s.failed} failed | ${s.passed} passed` +
75 (s.skipped > 0 ? ` | ${s.skipped} skipped` : "") +
76 (s.todo > 0 ? ` | ${s.todo} todo` : "") +
77 (s.plan
78 ? ` | ${s.plan.found} found | ${s.plan.selected} selected by plan | ${s.plan.excluded} excluded by plan`
79 : "") +
80 ` (${s.files} files, ${formatDuration(s.durationMs)})\n`
81 );
82 }
83 default:
84 return undefined;
85 }
86};
87
88export interface FileLog {
89 readonly append: (event: TestEvent) => Effect.Effect<void>;
90 /**
91 * Enqueue one live file-hook log line (prefixed with the file it belongs
92 * to). File-level hooks (beforeAll deploys / afterAll destroys) can run
93 * for many minutes; buffering their output until `FileEnd` makes the run
94 * log go silent for the whole time — a long-running deploy then reads as
95 * a deadlocked run. Streaming each entry as it is captured keeps the log
96 * tail-able mid-hook.
97 *
98 * This performs NO I/O: it only offers the formatted line to an
99 * unbounded queue drained by a background writer fiber (all file I/O is
100 * Effect-based and async). It is therefore safe to call from the
101 * synchronous array-push interception that captures hook output.
102 */
103 readonly appendHookLine: (file: string, entry: LogEntry) => void;
104 /**
105 * Gracefully end the hook-line stream: signals the queue, lets the
106 * writer fiber drain every enqueued line to disk, and awaits it. Call
107 * once at the end of the run so no tail lines are lost.
108 */
109 readonly close: Effect.Effect<void>;
110}
111
112const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
113
114/**
115 * Delete sibling run logs whose mtime (from stat — file names are never
116 * trusted) is older than a week, so the per-run log directory can't grow
117 * unboundedly. Best-effort: failures are ignored.
118 */
119const pruneOldLogs = Effect.fn(function* (dir: string) {
120 const fs = yield* FileSystem.FileSystem;
121 const path = yield* Path.Path;
122 const entries = yield* fs
123 .readDirectory(dir)
124 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
125 const cutoff = Date.now() - LOG_RETENTION_MS;
126 for (const entry of entries) {
127 if (!entry.endsWith(".log")) continue;
128 const file = path.join(dir, entry);
129 const info = yield* fs
130 .stat(file)
131 .pipe(Effect.orElseSucceed(() => undefined));
132 const mtime =
133 info === undefined ? undefined : Option.getOrUndefined(info.mtime);
134 if (mtime !== undefined && mtime.getTime() < cutoff) {
135 yield* fs.remove(file).pipe(Effect.ignore);
136 }
137 }
138});
139
140/** Create (truncate) the log file and return an event appender. */
141export const makeFileLog = Effect.fn(function* (logFile: string) {
142 const fs = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 yield* fs
145 .makeDirectory(path.dirname(logFile), { recursive: true })
146 .pipe(Effect.ignore);
147 yield* pruneOldLogs(path.dirname(logFile));
148 yield* fs.writeFileString(logFile, "").pipe(Effect.ignore);
149 const append: FileLog["append"] = (event) => {
150 const chunk = formatEvent(event);
151 if (chunk === undefined) return Effect.void;
152 return fs
153 .writeFileString(logFile, chunk, { flag: "a" })
154 .pipe(Effect.ignore);
155 };
156 // Hook lines flow through an unbounded queue to a single writer fiber so
157 // the capture site (a synchronous array-push interception) never performs
158 // I/O and never blocks: `offerUnsafe` on an unbounded queue is a plain
159 // in-memory enqueue. The writer serializes appends, so hook lines cannot
160 // interleave mid-line with the Effect-based `append` writes.
161 const hookLines = yield* Queue.make<string, Cause.Done>();
162 const writer = yield* Effect.forkChild(
163 Effect.gen(function* () {
164 while (true) {
165 const line = yield* Queue.take(hookLines);
166 yield* fs
167 .writeFileString(logFile, `${line}\n`, { flag: "a" })
168 .pipe(Effect.ignore);
169 }
170 }).pipe(
171 // `Queue.take` fails with `Done` once `close` ends the queue and the
172 // backlog is drained — that is the writer's normal exit.
173 Effect.catchCause(() => Effect.void),
174 ),
175 );
176 const appendHookLine: FileLog["appendHookLine"] = (file, entry) => {
177 const prefixed = entry.message
178 .split("\n")
179 .map((line) => `[hook ${file}] ${line}`)
180 .join("\n");
181 Queue.offerUnsafe(hookLines, prefixed);
182 };
183 const close: FileLog["close"] = Effect.gen(function* () {
184 yield* Queue.end(hookLines);
185 yield* Fiber.await(writer);
186 });
187 return { append, appendHookLine, close } satisfies FileLog;
188});