Files
Codewalkers/apps/server/agent/file-io.ts
Lukas May bd0aec4499 fix: Convert sync file I/O to async in agent spawn path to unblock event loop
writeInputFiles, spawnDetached, and diagnostic writes now use
fs/promises (mkdir, writeFile) instead of mkdirSync/writeFileSync.
File writes in writeInputFiles are batched with Promise.all.
openSync/closeSync for child process stdio FDs remain sync as
spawn() requires the FDs immediately.
2026-03-04 12:15:31 +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 { readdirSync, existsSync, readFileSync } from 'node:fs';
import { mkdir, writeFile } 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 function readFrontmatterFile(filePath: string): { data: Record<string, unknown>; body: string } | null {
try {
const raw = readFileSync(filePath, 'utf-8');
const parsed = matter(raw);
return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
} catch {
return null;
}
}
function readFrontmatterDir<T>(
dirPath: string,
mapper: (data: Record<string, unknown>, body: string, filename: string) => T | null,
): T[] {
if (!existsSync(dirPath)) return [];
const results: T[] = [];
try {
const entries = readdirSync(dirPath);
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const filePath = join(dirPath, entry);
const parsed = 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 function readSummary(agentWorkdir: string): ParsedSummary | null {
const filePath = join(agentWorkdir, '.cw', 'output', 'SUMMARY.md');
const parsed = 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 function readPhaseFiles(agentWorkdir: string): 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 function readTaskFiles(agentWorkdir: string): 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 function readDecisionFiles(agentWorkdir: string): 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 function readPageFiles(agentWorkdir: string): 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,
};
});
}