Files
Codewalkers/apps/server/agent/file-io.ts
Lukas May 73a4c6cb0c fix: Convert sync file I/O to async in read path to unblock event loop
readFrontmatterFile, readFrontmatterDir, readSummary, readPhaseFiles,
readTaskFiles, readDecisionFiles, and readPageFiles all used readFileSync
and readdirSync which block the Node.js event loop during agent completion
handling. Converted to async using readFile/readdir from fs/promises and
added await at all call sites in output-handler.ts.
2026-03-04 12:25:34 +01:00

415 lines
12 KiB
TypeScript

/**
* File-Based Agent I/O
*
* Writes context as input files before agent spawn and reads output files after completion.
* Uses YAML frontmatter (gray-matter) for structured metadata and markdown bodies.
*
* Input: .cw/input/ — written by system before spawn
* Output: .cw/output/ — written by agent during execution
*/
import { existsSync } from 'node:fs';
import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import matter from 'gray-matter';
import { nanoid } from 'nanoid';
import { tiptapJsonToMarkdown } from './content-serializer.js';
import type { AgentInputContext } from './types.js';
// Re-export for convenience
export type { AgentInputContext } from './types.js';
// =============================================================================
// TYPES
// =============================================================================
export interface WriteInputFilesOptions extends AgentInputContext {
agentWorkdir: string;
}
export interface ParsedSummary {
body: string;
filesModified?: string[];
}
export interface ParsedPhaseFile {
id: string;
title: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
}
export interface ParsedTaskFile {
id: string;
title: string;
category: string;
type: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
phaseId?: string;
parentTaskId?: string;
}
export interface ParsedDecisionFile {
id: string;
topic: string;
decision: string;
reason: string;
body: string;
}
export interface ParsedPageFile {
pageId: string;
title: string;
summary: string;
body: string;
action?: 'create' | 'update' | 'delete';
}
// =============================================================================
// ID GENERATION
// =============================================================================
export function generateId(): string {
return nanoid();
}
// =============================================================================
// INPUT FILE WRITING
// =============================================================================
function formatFrontmatter(data: Record<string, unknown>, body: string = ''): string {
const lines: string[] = ['---'];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${String(item)}`);
}
}
} else if (value instanceof Date) {
lines.push(`${key}: "${value.toISOString()}"`);
} else if (typeof value === 'string' && (value.includes('\n') || value.includes(':'))) {
lines.push(`${key}: ${JSON.stringify(value)}`);
} else {
lines.push(`${key}: ${String(value)}`);
}
}
lines.push('---');
if (body) {
lines.push('');
lines.push(body);
}
return lines.join('\n') + '\n';
}
export async function writeInputFiles(options: WriteInputFilesOptions): Promise<void> {
const inputDir = join(options.agentWorkdir, '.cw', 'input');
await mkdir(inputDir, { recursive: true });
// Write expected working directory marker for verification
await writeFile(
join(inputDir, '../expected-pwd.txt'),
options.agentWorkdir,
'utf-8'
);
const manifestFiles: string[] = [];
// Collect all file writes, then flush in parallel
const writes: Array<{ path: string; content: string }> = [];
if (options.initiative) {
const ini = options.initiative;
const content = formatFrontmatter(
{
id: ini.id,
name: ini.name,
status: ini.status,
mergeRequiresApproval: ini.mergeRequiresApproval,
branch: ini.branch,
},
'',
);
writes.push({ path: join(inputDir, 'initiative.md'), content });
manifestFiles.push('initiative.md');
}
if (options.pages && options.pages.length > 0) {
await mkdir(join(inputDir, 'pages'), { recursive: true });
for (const page of options.pages) {
let bodyMarkdown = '';
if (page.content) {
try {
const parsed = JSON.parse(page.content);
bodyMarkdown = tiptapJsonToMarkdown(parsed);
} catch {
// Invalid JSON content — skip
}
}
const content = formatFrontmatter(
{
title: page.title,
parentPageId: page.parentPageId,
sortOrder: page.sortOrder,
},
bodyMarkdown,
);
const filename = `pages/${page.id}.md`;
writes.push({ path: join(inputDir, 'pages', `${page.id}.md`), content });
manifestFiles.push(filename);
}
}
if (options.phase) {
const ph = options.phase;
let bodyMarkdown = '';
if (ph.content) {
try {
bodyMarkdown = tiptapJsonToMarkdown(JSON.parse(ph.content));
} catch {
// Invalid JSON content — skip
}
}
const content = formatFrontmatter(
{
id: ph.id,
name: ph.name,
status: ph.status,
},
bodyMarkdown,
);
writes.push({ path: join(inputDir, 'phase.md'), content });
manifestFiles.push('phase.md');
}
if (options.task) {
const t = options.task;
const content = formatFrontmatter(
{
id: t.id,
name: t.name,
category: t.category,
type: t.type,
priority: t.priority,
status: t.status,
},
t.description ?? '',
);
writes.push({ path: join(inputDir, 'task.md'), content });
manifestFiles.push('task.md');
}
// Write read-only context directories
const contextFiles: string[] = [];
if (options.phases && options.phases.length > 0) {
await mkdir(join(inputDir, 'context', 'phases'), { recursive: true });
for (const ph of options.phases) {
let bodyMarkdown = '';
if (ph.content) {
try {
bodyMarkdown = tiptapJsonToMarkdown(JSON.parse(ph.content));
} catch {
// Invalid JSON content — skip
}
}
const content = formatFrontmatter(
{
id: ph.id,
name: ph.name,
status: ph.status,
dependsOn: ph.dependsOn ?? [],
},
bodyMarkdown,
);
const filename = `context/phases/${ph.id}.md`;
writes.push({ path: join(inputDir, 'context', 'phases', `${ph.id}.md`), content });
contextFiles.push(filename);
}
}
if (options.tasks && options.tasks.length > 0) {
await mkdir(join(inputDir, 'context', 'tasks'), { recursive: true });
for (const t of options.tasks) {
const content = formatFrontmatter(
{
id: t.id,
name: t.name,
phaseId: t.phaseId,
parentTaskId: t.parentTaskId,
category: t.category,
type: t.type,
priority: t.priority,
status: t.status,
summary: t.summary,
},
t.description ?? '',
);
const filename = `context/tasks/${t.id}.md`;
writes.push({ path: join(inputDir, 'context', 'tasks', `${t.id}.md`), content });
contextFiles.push(filename);
}
}
// Write context index — groups tasks by phaseId so agents can look up relevant files
// without bulk-reading every context file
if (options.tasks && options.tasks.length > 0) {
const tasksByPhase: Record<string, Array<{ file: string; id: string; name: string; status: string }>> = {};
for (const t of options.tasks) {
const phaseId = t.phaseId ?? '_unassigned';
if (!tasksByPhase[phaseId]) tasksByPhase[phaseId] = [];
tasksByPhase[phaseId].push({
file: `context/tasks/${t.id}.md`,
id: t.id,
name: t.name,
status: t.status,
});
}
await mkdir(join(inputDir, 'context'), { recursive: true });
writes.push({
path: join(inputDir, 'context', 'index.json'),
content: JSON.stringify({ tasksByPhase }, null, 2) + '\n',
});
}
// Flush all file writes in parallel — yields the event loop between I/O ops
await Promise.all(writes.map(w => writeFile(w.path, w.content, 'utf-8')));
// Write manifest last (after all files exist)
await writeFile(
join(inputDir, 'manifest.json'),
JSON.stringify({
files: manifestFiles,
contextFiles,
agentId: options.agentId ?? null,
agentName: options.agentName ?? null,
}) + '\n',
'utf-8',
);
}
// =============================================================================
// OUTPUT FILE READING
// =============================================================================
export async function readFrontmatterFile(filePath: string): Promise<{ data: Record<string, unknown>; body: string } | null> {
try {
const raw = await readFile(filePath, 'utf-8');
const parsed = matter(raw);
return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
} catch {
return null;
}
}
async function readFrontmatterDir<T>(
dirPath: string,
mapper: (data: Record<string, unknown>, body: string, filename: string) => T | null,
): Promise<T[]> {
if (!existsSync(dirPath)) return [];
const results: T[] = [];
try {
const entries = await readdir(dirPath);
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const filePath = join(dirPath, entry);
const parsed = await readFrontmatterFile(filePath);
if (!parsed) continue;
const mapped = mapper(parsed.data, parsed.body, entry);
if (mapped) results.push(mapped);
}
} catch {
// Directory read error — return empty
}
return results;
}
export async function readSummary(agentWorkdir: string): Promise<ParsedSummary | null> {
const filePath = join(agentWorkdir, '.cw', 'output', 'SUMMARY.md');
const parsed = await readFrontmatterFile(filePath);
if (!parsed) return null;
const filesModified = parsed.data.files_modified;
return {
body: parsed.body,
filesModified: Array.isArray(filesModified) ? filesModified.map(String) : undefined,
};
}
export async function readPhaseFiles(agentWorkdir: string): Promise<ParsedPhaseFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'phases');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
dependencies: deps,
body,
action,
};
});
}
export async function readTaskFiles(agentWorkdir: string): Promise<ParsedTaskFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'tasks');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
category: String(data.category ?? 'execute'),
type: String(data.type ?? 'auto'),
dependencies: deps,
body,
action,
phaseId: data.phaseId ? String(data.phaseId) : undefined,
parentTaskId: data.parentTaskId ? String(data.parentTaskId) : undefined,
};
});
}
export async function readDecisionFiles(agentWorkdir: string): Promise<ParsedDecisionFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'decisions');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
return {
id,
topic: String(data.topic ?? ''),
decision: String(data.decision ?? ''),
reason: String(data.reason ?? ''),
body,
};
});
}
export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const pageId = filename.replace(/\.md$/, '');
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
pageId,
title: String(data.title ?? ''),
summary: String(data.summary ?? ''),
body,
action,
};
});
}