feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) (#77)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #77.
This commit is contained in:
376
packages/coord/src/tasks-file.ts
Normal file
376
packages/coord/src/tasks-file.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Mission, MissionTask, TaskStatus } from './types.js';
|
||||
import { normalizeTaskStatus } from './types.js';
|
||||
|
||||
const TASKS_LOCK_FILE = '.TASKS.md.lock';
|
||||
const TASKS_LOCK_STALE_MS = 5 * 60 * 1000;
|
||||
const TASKS_LOCK_WAIT_MS = 5 * 1000;
|
||||
const TASKS_LOCK_RETRY_MS = 100;
|
||||
|
||||
const DEFAULT_TABLE_HEADER = [
|
||||
'| id | status | milestone | description | pr | notes |',
|
||||
'|----|--------|-----------|-------------|----|-------|',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_TASKS_PREAMBLE = [
|
||||
'# Tasks',
|
||||
'',
|
||||
'> Single-writer: orchestrator only. Workers read but never modify.',
|
||||
'',
|
||||
...DEFAULT_TABLE_HEADER,
|
||||
] as const;
|
||||
|
||||
interface ParsedTableRow {
|
||||
readonly lineIndex: number;
|
||||
readonly cells: string[];
|
||||
}
|
||||
|
||||
interface ParsedTable {
|
||||
readonly headerLineIndex: number;
|
||||
readonly separatorLineIndex: number;
|
||||
readonly headers: string[];
|
||||
readonly rows: ParsedTableRow[];
|
||||
readonly idColumn: number;
|
||||
readonly statusColumn: number;
|
||||
}
|
||||
|
||||
function normalizeHeaderName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function splitMarkdownRow(line: string): string[] {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('|')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/(?<!\\)\|/);
|
||||
if (parts.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parts.slice(1, -1).map((part) => part.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function isSeparatorRow(cells: readonly string[]): boolean {
|
||||
return (
|
||||
cells.length > 0 &&
|
||||
cells.every((cell) => {
|
||||
const value = cell.trim();
|
||||
return /^:?-{3,}:?$/.test(value);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function parseTable(content: string): ParsedTable | undefined {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let headerLineIndex = -1;
|
||||
let separatorLineIndex = -1;
|
||||
let headers: string[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const cells = splitMarkdownRow(lines[index] as string);
|
||||
if (cells.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = cells.map(normalizeHeaderName);
|
||||
if (!normalized.includes('id') || !normalized.includes('status')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= lines.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorCells = splitMarkdownRow(lines[index + 1] as string);
|
||||
if (!isSeparatorRow(separatorCells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
headerLineIndex = index;
|
||||
separatorLineIndex = index + 1;
|
||||
headers = normalized;
|
||||
break;
|
||||
}
|
||||
|
||||
if (headerLineIndex < 0 || separatorLineIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const idColumn = headers.indexOf('id');
|
||||
const statusColumn = headers.indexOf('status');
|
||||
if (idColumn < 0 || statusColumn < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rows: ParsedTableRow[] = [];
|
||||
let sawData = false;
|
||||
|
||||
for (let index = separatorLineIndex + 1; index < lines.length; index += 1) {
|
||||
const rawLine = lines[index] as string;
|
||||
const trimmed = rawLine.trim();
|
||||
|
||||
if (!trimmed.startsWith('|')) {
|
||||
if (sawData) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const cells = splitMarkdownRow(rawLine);
|
||||
if (cells.length === 0) {
|
||||
if (sawData) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sawData = true;
|
||||
|
||||
const normalizedRow = [...cells];
|
||||
while (normalizedRow.length < headers.length) {
|
||||
normalizedRow.push('');
|
||||
}
|
||||
|
||||
rows.push({ lineIndex: index, cells: normalizedRow });
|
||||
}
|
||||
|
||||
return {
|
||||
headerLineIndex,
|
||||
separatorLineIndex,
|
||||
headers,
|
||||
rows,
|
||||
idColumn,
|
||||
statusColumn,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeTableCell(value: string): string {
|
||||
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatTableRow(cells: readonly string[]): string {
|
||||
const escaped = cells.map((cell) => escapeTableCell(cell));
|
||||
return `| ${escaped.join(' | ')} |`;
|
||||
}
|
||||
|
||||
function parseDependencies(raw: string | undefined): string[] {
|
||||
if (raw === undefined || raw.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function resolveTasksFilePath(mission: Mission): string {
|
||||
if (path.isAbsolute(mission.tasksFile)) {
|
||||
return mission.tasksFile;
|
||||
}
|
||||
|
||||
return path.join(mission.projectPath, mission.tasksFile);
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function acquireLock(lockPath: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) {
|
||||
try {
|
||||
const handle = await fs.open(lockPath, 'wx');
|
||||
await handle.writeFile(
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await handle.close();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorWithCode(error, 'EEXIST')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(lockPath);
|
||||
if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
} catch (statError) {
|
||||
if (!isNodeErrorWithCode(statError, 'ENOENT')) {
|
||||
throw statError;
|
||||
}
|
||||
}
|
||||
|
||||
await delay(TASKS_LOCK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
async function releaseLock(lockPath: string): Promise<void> {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
}
|
||||
|
||||
async function writeAtomic(filePath: string, content: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempPath, content, 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
export function parseTasksFile(content: string): MissionTask[] {
|
||||
const parsedTable = parseTable(content);
|
||||
if (parsedTable === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headerToColumn = new Map<string, number>();
|
||||
parsedTable.headers.forEach((header, index) => {
|
||||
headerToColumn.set(header, index);
|
||||
});
|
||||
|
||||
const descriptionColumn = headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1;
|
||||
const milestoneColumn = headerToColumn.get('milestone') ?? -1;
|
||||
const prColumn = headerToColumn.get('pr') ?? -1;
|
||||
const notesColumn = headerToColumn.get('notes') ?? -1;
|
||||
const assigneeColumn = headerToColumn.get('assignee') ?? -1;
|
||||
const dependenciesColumn = headerToColumn.get('dependencies') ?? -1;
|
||||
|
||||
const tasks: MissionTask[] = [];
|
||||
|
||||
for (const row of parsedTable.rows) {
|
||||
const id = row.cells[parsedTable.idColumn]?.trim();
|
||||
if (id === undefined || id.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawStatusValue = row.cells[parsedTable.statusColumn] ?? '';
|
||||
const normalized = normalizeTaskStatus(rawStatusValue);
|
||||
|
||||
const title = descriptionColumn >= 0 ? (row.cells[descriptionColumn] ?? '') : '';
|
||||
const milestone = milestoneColumn >= 0 ? (row.cells[milestoneColumn] ?? '') : '';
|
||||
const pr = prColumn >= 0 ? (row.cells[prColumn] ?? '') : '';
|
||||
const notes = notesColumn >= 0 ? (row.cells[notesColumn] ?? '') : '';
|
||||
const assignee = assigneeColumn >= 0 ? (row.cells[assigneeColumn] ?? '') : '';
|
||||
const dependenciesRaw = dependenciesColumn >= 0 ? (row.cells[dependenciesColumn] ?? '') : '';
|
||||
|
||||
tasks.push({
|
||||
id,
|
||||
title,
|
||||
status: normalized.status,
|
||||
dependencies: parseDependencies(dependenciesRaw),
|
||||
milestone: milestone.length > 0 ? milestone : undefined,
|
||||
pr: pr.length > 0 ? pr : undefined,
|
||||
notes: notes.length > 0 ? notes : undefined,
|
||||
assignee: assignee.length > 0 ? assignee : undefined,
|
||||
rawStatus: normalized.rawStatus,
|
||||
line: row.lineIndex + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export function writeTasksFile(tasks: MissionTask[]): string {
|
||||
const lines: string[] = [...DEFAULT_TASKS_PREAMBLE];
|
||||
|
||||
for (const task of tasks) {
|
||||
lines.push(
|
||||
formatTableRow([
|
||||
task.id,
|
||||
task.status,
|
||||
task.milestone ?? '',
|
||||
task.title,
|
||||
task.pr ?? '',
|
||||
task.notes ?? '',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(
|
||||
mission: Mission,
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
): Promise<void> {
|
||||
const tasksFilePath = resolveTasksFilePath(mission);
|
||||
const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE);
|
||||
|
||||
await fs.mkdir(path.dirname(tasksFilePath), { recursive: true });
|
||||
await acquireLock(lockPath);
|
||||
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(tasksFilePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||
throw new Error(`TASKS file not found: ${tasksFilePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const table = parseTable(content);
|
||||
if (table === undefined) {
|
||||
throw new Error(`Could not parse TASKS table in ${tasksFilePath}`);
|
||||
}
|
||||
|
||||
const matchingRows = table.rows.filter((row) => {
|
||||
const rowTaskId = row.cells[table.idColumn]?.trim();
|
||||
return rowTaskId === taskId;
|
||||
});
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
throw new Error(`Task not found in TASKS.md: ${taskId}`);
|
||||
}
|
||||
|
||||
if (matchingRows.length > 1) {
|
||||
throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`);
|
||||
}
|
||||
|
||||
const targetRow = matchingRows[0] as ParsedTableRow;
|
||||
const updatedCells = [...targetRow.cells];
|
||||
updatedCells[table.statusColumn] = status;
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
lines[targetRow.lineIndex] = formatTableRow(updatedCells);
|
||||
|
||||
const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
||||
await writeAtomic(tasksFilePath, updatedContent);
|
||||
} finally {
|
||||
await releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user