import { promises as fs } from 'node:fs';
import path from 'node:path';
import { getAlwaysOnRoot } from './always-on-paths.js';
function normalizeRunId(runId) {
return typeof runId === 'string'
? runId.trim().replace(/[^a-zA-Z0-9._:-]/g, '-')
: '';
}
function ensureTrailingNewline(value) {
return value.endsWith('\n') ? value : `${value}\n`;
}
function getAlwaysOnRunsDir(projectRoot) {
return path.join(getAlwaysOnRoot(projectRoot), 'runs');
}
function getRunLogPath(projectRoot, runId) {
const safeRunId = normalizeRunId(runId);
if (!safeRunId) {
throw new Error('runId is required');
}
return path.join(getAlwaysOnRunsDir(projectRoot), `${safeRunId}.log`);
}
function getRunEventsPath(projectRoot, runId) {
const safeRunId = normalizeRunId(runId);
if (!safeRunId) {
throw new Error('runId is required');
}
return path.join(getAlwaysOnRunsDir(projectRoot), `${safeRunId}.events.jsonl`);
}
export function formatAlwaysOnPlanLogLine({
timestamp = new Date().toISOString(),
level = 'info',
runId,
planId,
phase,
message,
}) {
const safeMessage = String(message || '').replace(/\s+/g, ' ').trim();
return `[AlwaysOnPlanRun] ts=${timestamp} level=${level} runId=${runId} planId=${planId} phase=${phase} message=${JSON.stringify(safeMessage)}`;
}
export async function appendAlwaysOnRunLog(projectRoot, runId, lines) {
const values = Array.isArray(lines) ? lines : [lines];
const content = values
.map((line) => (typeof line === 'string' ? line : String(line ?? '')))
.filter((line) => line.length > 0)
.map(ensureTrailingNewline)
.join('');
if (!content) {
return;
}
await fs.mkdir(getAlwaysOnRunsDir(projectRoot), { recursive: true });
await fs.appendFile(getRunLogPath(projectRoot, runId), content, 'utf8');
}
export async function appendAlwaysOnRunLogEvent(projectRoot, runId, event) {
await fs.mkdir(getAlwaysOnRunsDir(projectRoot), { recursive: true });
await fs.appendFile(
getRunEventsPath(projectRoot, runId),
`${JSON.stringify({
timestamp: new Date().toISOString(),
...event,
runId,
})}\n`,
'utf8',
);
}