Files
Codewalkers/apps/server/agent/file-io.ts
Lukas May fcf822363c feat: Add persistent chat sessions for iterative phase/task refinement
Introduces a chat loop where users send instructions to an agent that
applies changes (create/update/delete phases, tasks, pages) and stays
alive for follow-up messages. Includes schema + migration, repository
layer, chat prompt, file-io action field extension, output handler chat
mode, revert support for deletes, tRPC procedures, events, frontend
slide-over UI with inline changeset display and revert, and docs.
2026-03-04 10:14:28 +01:00

391 lines
11 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 { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
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 function writeInputFiles(options: WriteInputFilesOptions): void {
const inputDir = join(options.agentWorkdir, '.cw', 'input');
mkdirSync(inputDir, { recursive: true });
// Write expected working directory marker for verification
writeFileSync(
join(inputDir, '../expected-pwd.txt'),
options.agentWorkdir,
'utf-8'
);
const manifestFiles: 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,
},
'',
);
writeFileSync(join(inputDir, 'initiative.md'), content, 'utf-8');
manifestFiles.push('initiative.md');
}
if (options.pages && options.pages.length > 0) {
const pagesDir = join(inputDir, 'pages');
mkdirSync(pagesDir, { 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`;
writeFileSync(join(pagesDir, `${page.id}.md`), content, 'utf-8');
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,
);
writeFileSync(join(inputDir, 'phase.md'), content, 'utf-8');
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 ?? '',
);
writeFileSync(join(inputDir, 'task.md'), content, 'utf-8');
manifestFiles.push('task.md');
}
// Write read-only context directories
const contextFiles: string[] = [];
if (options.phases && options.phases.length > 0) {
const phasesDir = join(inputDir, 'context', 'phases');
mkdirSync(phasesDir, { 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`;
writeFileSync(join(phasesDir, `${ph.id}.md`), content, 'utf-8');
contextFiles.push(filename);
}
}
if (options.tasks && options.tasks.length > 0) {
const tasksDir = join(inputDir, 'context', 'tasks');
mkdirSync(tasksDir, { 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`;
writeFileSync(join(tasksDir, `${t.id}.md`), content, 'utf-8');
contextFiles.push(filename);
}
}
// Write manifest listing exactly which files were created
writeFileSync(
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,
};
});
}