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:
175
packages/coord/src/status.ts
Normal file
175
packages/coord/src/status.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user