Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
176 lines
4.7 KiB
TypeScript
176 lines
4.7 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { loadMission } from './mission.js';
|
|
import { parseTasksFile } from './tasks-file.js';
|
|
import type {
|
|
Mission,
|
|
MissionSession,
|
|
MissionStatusSummary,
|
|
MissionTask,
|
|
TaskDetail,
|
|
} from './types.js';
|
|
|
|
const SESSION_LOCK_FILE = 'session.lock';
|
|
|
|
interface SessionLockState {
|
|
session_id?: string;
|
|
runtime?: string;
|
|
pid?: number;
|
|
started_at?: string;
|
|
milestone_id?: string;
|
|
}
|
|
|
|
function tasksFilePath(mission: Mission): string {
|
|
if (path.isAbsolute(mission.tasksFile)) {
|
|
return mission.tasksFile;
|
|
}
|
|
return path.join(mission.projectPath, mission.tasksFile);
|
|
}
|
|
|
|
function sessionLockPath(mission: Mission): string {
|
|
const orchestratorDir = path.isAbsolute(mission.orchestratorDir)
|
|
? mission.orchestratorDir
|
|
: path.join(mission.projectPath, mission.orchestratorDir);
|
|
|
|
return path.join(orchestratorDir, SESSION_LOCK_FILE);
|
|
}
|
|
|
|
function isPidAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readTasks(mission: Mission): Promise<MissionTask[]> {
|
|
try {
|
|
const content = await fs.readFile(tasksFilePath(mission), 'utf8');
|
|
return parseTasksFile(content);
|
|
} catch (error) {
|
|
if (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'code' in error &&
|
|
(error as { code?: string }).code === 'ENOENT'
|
|
) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readActiveSession(mission: Mission): Promise<MissionSession | undefined> {
|
|
let lockRaw: string;
|
|
try {
|
|
lockRaw = await fs.readFile(sessionLockPath(mission), 'utf8');
|
|
} catch (error) {
|
|
if (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'code' in error &&
|
|
(error as { code?: string }).code === 'ENOENT'
|
|
) {
|
|
return undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const lock = JSON.parse(lockRaw) as SessionLockState;
|
|
if (
|
|
typeof lock.session_id !== 'string' ||
|
|
(lock.runtime !== 'claude' && lock.runtime !== 'codex') ||
|
|
typeof lock.started_at !== 'string'
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const pid = typeof lock.pid === 'number' ? lock.pid : undefined;
|
|
if (pid !== undefined && pid > 0 && !isPidAlive(pid)) {
|
|
return undefined;
|
|
}
|
|
|
|
const existingSession = mission.sessions.find((session) => session.sessionId === lock.session_id);
|
|
if (existingSession !== undefined) {
|
|
return existingSession;
|
|
}
|
|
|
|
return {
|
|
sessionId: lock.session_id,
|
|
runtime: lock.runtime,
|
|
pid,
|
|
startedAt: lock.started_at,
|
|
milestoneId: lock.milestone_id,
|
|
};
|
|
}
|
|
|
|
export async function getMissionStatus(mission: Mission): Promise<MissionStatusSummary> {
|
|
const freshMission = await loadMission(mission.projectPath);
|
|
const tasks = await readTasks(freshMission);
|
|
|
|
const done = tasks.filter((task) => task.status === 'done').length;
|
|
const inProgress = tasks.filter((task) => task.status === 'in-progress').length;
|
|
const pending = tasks.filter((task) => task.status === 'not-started').length;
|
|
const blocked = tasks.filter((task) => task.status === 'blocked').length;
|
|
const cancelled = tasks.filter((task) => task.status === 'cancelled').length;
|
|
const nextTask = tasks.find((task) => task.status === 'not-started');
|
|
|
|
const completedMilestones = freshMission.milestones.filter(
|
|
(milestone) => milestone.status === 'completed',
|
|
).length;
|
|
const currentMilestone =
|
|
freshMission.milestones.find((milestone) => milestone.status === 'in-progress') ??
|
|
freshMission.milestones.find((milestone) => milestone.status === 'pending');
|
|
|
|
const activeSession = await readActiveSession(freshMission);
|
|
|
|
return {
|
|
mission: {
|
|
id: freshMission.id,
|
|
name: freshMission.name,
|
|
status: freshMission.status,
|
|
projectPath: freshMission.projectPath,
|
|
},
|
|
milestones: {
|
|
total: freshMission.milestones.length,
|
|
completed: completedMilestones,
|
|
current: currentMilestone,
|
|
},
|
|
tasks: {
|
|
total: tasks.length,
|
|
done,
|
|
inProgress,
|
|
pending,
|
|
blocked,
|
|
cancelled,
|
|
},
|
|
nextTaskId: nextTask?.id,
|
|
activeSession,
|
|
};
|
|
}
|
|
|
|
export async function getTaskStatus(mission: Mission, taskId: string): Promise<TaskDetail> {
|
|
const freshMission = await loadMission(mission.projectPath);
|
|
const tasks = await readTasks(freshMission);
|
|
|
|
const matches = tasks.filter((task) => task.id === taskId);
|
|
if (matches.length === 0) {
|
|
throw new Error(`Task not found: ${taskId}`);
|
|
}
|
|
|
|
if (matches.length > 1) {
|
|
throw new Error(`Duplicate task IDs found: ${taskId}`);
|
|
}
|
|
|
|
const summary = await getMissionStatus(freshMission);
|
|
|
|
return {
|
|
missionId: freshMission.id,
|
|
task: matches[0]!,
|
|
isNextTask: summary.nextTaskId === taskId,
|
|
activeSession: summary.activeSession,
|
|
};
|
|
}
|