Files
stack/packages/coord/src/status.ts
2026-03-13 03:32:20 +00:00

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,
};
}