feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) #77
@@ -16,6 +16,7 @@
|
||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||
"@mosaic/auth": "workspace:^",
|
||||
"@mosaic/brain": "workspace:^",
|
||||
"@mosaic/coord": "workspace:^",
|
||||
"@mosaic/db": "workspace:^",
|
||||
"@mosaic/types": "workspace:^",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { RoutingService } from './routing.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule],
|
||||
providers: [ProviderService, RoutingService, AgentService],
|
||||
controllers: [ProvidersController],
|
||||
exports: [AgentService, ProviderService, RoutingService],
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { createBrainTools } from './tools/brain-tools.js';
|
||||
import { createCoordTools } from './tools/coord-tools.js';
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
provider?: string;
|
||||
@@ -36,8 +38,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly providerService: ProviderService,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
private readonly coordService: CoordService,
|
||||
) {
|
||||
this.customTools = createBrainTools(brain);
|
||||
this.customTools = [...createBrainTools(brain), ...createCoordTools(coordService)];
|
||||
this.logger.log(`Registered ${this.customTools.length} custom tools`);
|
||||
}
|
||||
|
||||
|
||||
81
apps/gateway/src/agent/tools/coord-tools.ts
Normal file
81
apps/gateway/src/agent/tools/coord-tools.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { CoordService } from '../../coord/coord.service.js';
|
||||
|
||||
export function createCoordTools(coordService: CoordService): ToolDefinition[] {
|
||||
const getMissionStatus: ToolDefinition = {
|
||||
name: 'coord_mission_status',
|
||||
label: 'Mission Status',
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
parameters: Type.Object({
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { projectPath } = params as { projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await coordService.getMissionStatus(resolvedPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listCoordTasks: ToolDefinition = {
|
||||
name: 'coord_list_tasks',
|
||||
label: 'List Coord Tasks',
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
parameters: Type.Object({
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { projectPath } = params as { projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await coordService.listTasks(resolvedPath);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getCoordTaskDetail: ToolDefinition = {
|
||||
name: 'coord_task_detail',
|
||||
label: 'Coord Task Detail',
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
parameters: Type.Object({
|
||||
taskId: Type.String({ description: 'Task ID (e.g. P2-005)' }),
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { taskId, projectPath } = params as { taskId: string; projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await coordService.getTaskStatus(resolvedPath, taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: detail
|
||||
? JSON.stringify(detail, null, 2)
|
||||
: `Task ${taskId} not found in coord mission.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [getMissionStatus, listCoordTasks, getCoordTaskDetail];
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { createBrainTools } from './brain-tools.js';
|
||||
export { createCoordTools } from './coord-tools.js';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConversationsModule } from './conversations/conversations.module.js';
|
||||
import { ProjectsModule } from './projects/projects.module.js';
|
||||
import { MissionsModule } from './missions/missions.module.js';
|
||||
import { TasksModule } from './tasks/tasks.module.js';
|
||||
import { CoordModule } from './coord/coord.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { TasksModule } from './tasks/tasks.module.js';
|
||||
ProjectsModule,
|
||||
MissionsModule,
|
||||
TasksModule,
|
||||
CoordModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
31
apps/gateway/src/coord/coord.controller.ts
Normal file
31
apps/gateway/src/coord/coord.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, NotFoundException, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CoordService } from './coord.service.js';
|
||||
|
||||
@Controller('api/coord')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CoordController {
|
||||
constructor(private readonly coordService: CoordService) {}
|
||||
|
||||
@Get('status')
|
||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
if (!status) throw new NotFoundException('No active coord mission found');
|
||||
return status;
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
async listTasks(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
return this.coordService.listTasks(resolvedPath);
|
||||
}
|
||||
|
||||
@Get('tasks/:taskId')
|
||||
async taskStatus(@Param('taskId') taskId: string, @Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
49
apps/gateway/src/coord/coord.dto.ts
Normal file
49
apps/gateway/src/coord/coord.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface CoordMissionStatusDto {
|
||||
mission: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectPath: string;
|
||||
};
|
||||
milestones: {
|
||||
total: number;
|
||||
completed: number;
|
||||
current?: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
done: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
blocked: number;
|
||||
cancelled: number;
|
||||
};
|
||||
nextTaskId?: string;
|
||||
activeSession?: {
|
||||
sessionId: string;
|
||||
runtime: string;
|
||||
startedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoordTaskDetailDto {
|
||||
missionId: string;
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
milestone?: string;
|
||||
pr?: string;
|
||||
notes?: string;
|
||||
};
|
||||
isNextTask: boolean;
|
||||
activeSession?: {
|
||||
sessionId: string;
|
||||
runtime: string;
|
||||
startedAt: string;
|
||||
};
|
||||
}
|
||||
10
apps/gateway/src/coord/coord.module.ts
Normal file
10
apps/gateway/src/coord/coord.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import { CoordController } from './coord.controller.js';
|
||||
|
||||
@Module({
|
||||
providers: [CoordService],
|
||||
controllers: [CoordController],
|
||||
exports: [CoordService],
|
||||
})
|
||||
export class CoordModule {}
|
||||
73
apps/gateway/src/coord/coord.service.ts
Normal file
73
apps/gateway/src/coord/coord.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
loadMission,
|
||||
getMissionStatus,
|
||||
getTaskStatus,
|
||||
parseTasksFile,
|
||||
type Mission,
|
||||
type MissionStatusSummary,
|
||||
type MissionTask,
|
||||
type TaskDetail,
|
||||
} from '@mosaic/coord';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class CoordService {
|
||||
private readonly logger = new Logger(CoordService.name);
|
||||
|
||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||
try {
|
||||
return await loadMission(projectPath);
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`No coord mission at ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getMissionStatus(projectPath: string): Promise<MissionStatusSummary | null> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return null;
|
||||
|
||||
try {
|
||||
return await getMissionStatus(mission);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to get mission status: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTaskStatus(projectPath: string, taskId: string): Promise<TaskDetail | null> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return null;
|
||||
|
||||
try {
|
||||
return await getTaskStatus(mission, taskId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to get task status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(projectPath: string): Promise<MissionTask[]> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return [];
|
||||
|
||||
const tasksFile = path.isAbsolute(mission.tasksFile)
|
||||
? mission.tasksFile
|
||||
: path.join(mission.projectPath, mission.tasksFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(tasksFile, 'utf8');
|
||||
return parseTasksFile(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,12 @@
|
||||
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
||||
| P1-009 | not-started | Phase 1 | Verify Phase 1 — gateway functional, API tested | — | #18 |
|
||||
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
|
||||
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
|
||||
| P2-002 | not-started | Phase 2 | Multi-provider support — Anthropic + Ollama | — | #20 |
|
||||
| P2-003 | not-started | Phase 2 | Agent routing engine — cost/capability matrix | — | #21 |
|
||||
| P2-004 | not-started | Phase 2 | Tool registration — brain, queue, memory tools | — | #22 |
|
||||
| P2-005 | not-started | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | — | #23 |
|
||||
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
|
||||
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
|
||||
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
|
||||
| P2-005 | in-progress | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | — | #23 |
|
||||
| P2-006 | not-started | Phase 2 | Agent session management — tmux + monitoring | — | #24 |
|
||||
| P2-007 | not-started | Phase 2 | Verify Phase 2 — multi-provider routing works | — | #25 |
|
||||
| P3-001 | not-started | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | — | #26 |
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@mosaic/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
export const VERSION = '0.0.0';
|
||||
export { createMission, loadMission, missionFilePath, saveMission } from './mission.js';
|
||||
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
||||
export { runTask, resumeTask } from './runner.js';
|
||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||
export type {
|
||||
CreateMissionOptions,
|
||||
Mission,
|
||||
MissionMilestone,
|
||||
MissionRuntime,
|
||||
MissionSession,
|
||||
MissionStatus,
|
||||
MissionStatusSummary,
|
||||
MissionTask,
|
||||
NextTaskCapsule,
|
||||
RunTaskOptions,
|
||||
TaskDetail,
|
||||
TaskRun,
|
||||
TaskStatus,
|
||||
} from './types.js';
|
||||
export { isMissionStatus, isTaskStatus, normalizeTaskStatus } from './types.js';
|
||||
|
||||
388
packages/coord/src/mission.ts
Normal file
388
packages/coord/src/mission.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { writeTasksFile } from './tasks-file.js';
|
||||
import type { CreateMissionOptions, Mission, MissionMilestone, MissionSession } from './types.js';
|
||||
import { isMissionStatus } from './types.js';
|
||||
|
||||
const DEFAULT_ORCHESTRATOR_DIR = '.mosaic/orchestrator';
|
||||
const DEFAULT_MISSION_FILE = 'mission.json';
|
||||
const DEFAULT_TASKS_FILE = 'docs/TASKS.md';
|
||||
const DEFAULT_MANIFEST_FILE = 'docs/MISSION-MANIFEST.md';
|
||||
const DEFAULT_SCRATCHPAD_DIR = 'docs/scratchpads';
|
||||
const DEFAULT_MILESTONE_VERSION = '0.0.1';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function readString(
|
||||
source: Record<string, unknown>,
|
||||
...keys: readonly string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumber(
|
||||
source: Record<string, unknown>,
|
||||
...keys: readonly string[]
|
||||
): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMilestoneStatus(status: string | undefined): MissionMilestone['status'] {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'in-progress') return 'in-progress';
|
||||
if (status === 'blocked') return 'blocked';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function normalizeSessionRuntime(runtime: string | undefined): MissionSession['runtime'] {
|
||||
if (runtime === 'claude' || runtime === 'codex' || runtime === 'unknown') {
|
||||
return runtime;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function normalizeEndedReason(reason: string | undefined): MissionSession['endedReason'] {
|
||||
if (
|
||||
reason === 'completed' ||
|
||||
reason === 'paused' ||
|
||||
reason === 'crashed' ||
|
||||
reason === 'killed' ||
|
||||
reason === 'unknown'
|
||||
) {
|
||||
return reason;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMission(raw: unknown, resolvedProjectPath: string): Mission {
|
||||
const source = asRecord(raw);
|
||||
|
||||
const id = readString(source, 'id', 'mission_id') ?? 'mission';
|
||||
const name = readString(source, 'name') ?? 'Unnamed Mission';
|
||||
const statusCandidate = readString(source, 'status') ?? 'inactive';
|
||||
const status = isMissionStatus(statusCandidate) ? statusCandidate : 'inactive';
|
||||
|
||||
const mission: Mission = {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name,
|
||||
description: readString(source, 'description'),
|
||||
projectPath: readString(source, 'projectPath', 'project_path') ?? resolvedProjectPath,
|
||||
createdAt: readString(source, 'createdAt', 'created_at') ?? new Date().toISOString(),
|
||||
status,
|
||||
tasksFile: readString(source, 'tasksFile', 'tasks_file') ?? DEFAULT_TASKS_FILE,
|
||||
manifestFile: readString(source, 'manifestFile', 'manifest_file') ?? DEFAULT_MANIFEST_FILE,
|
||||
scratchpadFile:
|
||||
readString(source, 'scratchpadFile', 'scratchpad_file') ??
|
||||
`${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
|
||||
orchestratorDir:
|
||||
readString(source, 'orchestratorDir', 'orchestrator_dir') ?? DEFAULT_ORCHESTRATOR_DIR,
|
||||
taskPrefix: readString(source, 'taskPrefix', 'task_prefix'),
|
||||
qualityGates: readString(source, 'qualityGates', 'quality_gates'),
|
||||
milestoneVersion: readString(source, 'milestoneVersion', 'milestone_version'),
|
||||
milestones: [],
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
const milestonesRaw = Array.isArray(source.milestones) ? source.milestones : [];
|
||||
mission.milestones = milestonesRaw.map(
|
||||
(milestoneValue: unknown, index: number): MissionMilestone => {
|
||||
const milestone = asRecord(milestoneValue);
|
||||
return {
|
||||
id: readString(milestone, 'id') ?? `phase-${index + 1}`,
|
||||
name: readString(milestone, 'name') ?? `Phase ${index + 1}`,
|
||||
status: normalizeMilestoneStatus(readString(milestone, 'status')),
|
||||
branch: readString(milestone, 'branch'),
|
||||
issueRef: readString(milestone, 'issueRef', 'issue_ref'),
|
||||
startedAt: readString(milestone, 'startedAt', 'started_at'),
|
||||
completedAt: readString(milestone, 'completedAt', 'completed_at'),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const sessionsRaw = Array.isArray(source.sessions) ? source.sessions : [];
|
||||
mission.sessions = sessionsRaw.map((sessionValue: unknown, index: number): MissionSession => {
|
||||
const session = asRecord(sessionValue);
|
||||
const fallbackSessionId = `sess-${String(index + 1).padStart(3, '0')}`;
|
||||
|
||||
return {
|
||||
sessionId: readString(session, 'sessionId', 'session_id') ?? fallbackSessionId,
|
||||
runtime: normalizeSessionRuntime(readString(session, 'runtime')),
|
||||
pid: readNumber(session, 'pid'),
|
||||
startedAt: readString(session, 'startedAt', 'started_at') ?? mission.createdAt,
|
||||
endedAt: readString(session, 'endedAt', 'ended_at'),
|
||||
endedReason: normalizeEndedReason(readString(session, 'endedReason', 'ended_reason')),
|
||||
milestoneId: readString(session, 'milestoneId', 'milestone_id'),
|
||||
lastTaskId: readString(session, 'lastTaskId', 'last_task_id'),
|
||||
durationSeconds: readNumber(session, 'durationSeconds', 'duration_seconds'),
|
||||
};
|
||||
});
|
||||
|
||||
return mission;
|
||||
}
|
||||
|
||||
function missionIdFromName(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
return `${slug || 'mission'}-${date}`;
|
||||
}
|
||||
|
||||
function toAbsolutePath(basePath: string, targetPath: string): string {
|
||||
if (path.isAbsolute(targetPath)) {
|
||||
return targetPath;
|
||||
}
|
||||
return path.join(basePath, targetPath);
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempPath, content, 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
function renderManifest(mission: Mission): string {
|
||||
const milestoneRows = mission.milestones
|
||||
.map((milestone, index) => {
|
||||
const issue = milestone.issueRef ?? '—';
|
||||
const branch = milestone.branch ?? '—';
|
||||
const started = milestone.startedAt ?? '—';
|
||||
const completed = milestone.completedAt ?? '—';
|
||||
return `| ${index + 1} | ${milestone.id} | ${milestone.name} | ${milestone.status} | ${branch} | ${issue} | ${started} | ${completed} |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const body = [
|
||||
`# Mission Manifest — ${mission.name}`,
|
||||
'',
|
||||
'> Persistent document tracking full mission scope, status, and session history.',
|
||||
'',
|
||||
'## Mission',
|
||||
'',
|
||||
`**ID:** ${mission.id}`,
|
||||
`**Statement:** ${mission.description ?? ''}`,
|
||||
'**Phase:** Intake',
|
||||
'**Current Milestone:** —',
|
||||
`**Progress:** 0 / ${mission.milestones.length} milestones`,
|
||||
`**Status:** ${mission.status}`,
|
||||
`**Last Updated:** ${new Date().toISOString().replace('T', ' ').replace(/\..+/, ' UTC')}`,
|
||||
'',
|
||||
'## Milestones',
|
||||
'',
|
||||
'| # | ID | Name | Status | Branch | Issue | Started | Completed |',
|
||||
'|---|-----|------|--------|--------|-------|---------|-----------|',
|
||||
milestoneRows,
|
||||
'',
|
||||
'## Session History',
|
||||
'',
|
||||
'| Session | Runtime | Started | Duration | Ended Reason | Last Task |',
|
||||
'|---------|---------|---------|----------|--------------|-----------|',
|
||||
'',
|
||||
`## Scratchpad\n\nPath: \`${mission.scratchpadFile}\``,
|
||||
'',
|
||||
];
|
||||
|
||||
return body.join('\n');
|
||||
}
|
||||
|
||||
function renderScratchpad(mission: Mission): string {
|
||||
return [
|
||||
`# Mission Scratchpad — ${mission.name}`,
|
||||
'',
|
||||
'> Append-only log. NEVER delete entries. NEVER overwrite sections.',
|
||||
'',
|
||||
'## Original Mission Prompt',
|
||||
'',
|
||||
'```',
|
||||
'(Paste the mission prompt here on first session)',
|
||||
'```',
|
||||
'',
|
||||
'## Planning Decisions',
|
||||
'',
|
||||
'## Session Log',
|
||||
'',
|
||||
'| Session | Date | Milestone | Tasks Done | Outcome |',
|
||||
'|---------|------|-----------|------------|---------|',
|
||||
'',
|
||||
'## Open Questions',
|
||||
'',
|
||||
'## Corrections',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildMissionFromOptions(
|
||||
options: CreateMissionOptions,
|
||||
resolvedProjectPath: string,
|
||||
): Mission {
|
||||
const id = missionIdFromName(options.name);
|
||||
const milestones = (options.milestones ?? []).map((name, index): MissionMilestone => {
|
||||
const cleanName = name.trim();
|
||||
const milestoneName = cleanName.length > 0 ? cleanName : `Phase ${index + 1}`;
|
||||
return {
|
||||
id: `phase-${index + 1}`,
|
||||
name: milestoneName,
|
||||
status: 'pending' as const,
|
||||
branch: milestoneName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
projectPath: resolvedProjectPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'active',
|
||||
tasksFile: DEFAULT_TASKS_FILE,
|
||||
manifestFile: DEFAULT_MANIFEST_FILE,
|
||||
scratchpadFile: `${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
|
||||
orchestratorDir: DEFAULT_ORCHESTRATOR_DIR,
|
||||
taskPrefix: options.prefix,
|
||||
qualityGates: options.qualityGates,
|
||||
milestoneVersion: options.version ?? DEFAULT_MILESTONE_VERSION,
|
||||
milestones,
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function missionFilePath(projectPath: string, mission?: Mission): string {
|
||||
const orchestratorDir = mission?.orchestratorDir ?? DEFAULT_ORCHESTRATOR_DIR;
|
||||
const baseDir = path.isAbsolute(orchestratorDir)
|
||||
? orchestratorDir
|
||||
: path.join(projectPath, orchestratorDir);
|
||||
return path.join(baseDir, DEFAULT_MISSION_FILE);
|
||||
}
|
||||
|
||||
export async function saveMission(mission: Mission): Promise<void> {
|
||||
const filePath = missionFilePath(mission.projectPath, mission);
|
||||
const payload = `${JSON.stringify(mission, null, 2)}\n`;
|
||||
await writeFileAtomic(filePath, payload);
|
||||
}
|
||||
|
||||
export async function createMission(options: CreateMissionOptions): Promise<Mission> {
|
||||
const name = options.name.trim();
|
||||
if (name.length === 0) {
|
||||
throw new Error('Mission name is required');
|
||||
}
|
||||
|
||||
const resolvedProjectPath = path.resolve(options.projectPath ?? process.cwd());
|
||||
const mission = buildMissionFromOptions({ ...options, name }, resolvedProjectPath);
|
||||
|
||||
const missionPath = missionFilePath(resolvedProjectPath, mission);
|
||||
const hasExistingMission = await fileExists(missionPath);
|
||||
|
||||
if (hasExistingMission) {
|
||||
const existingRaw = await fs.readFile(missionPath, 'utf8');
|
||||
const existingMission = normalizeMission(JSON.parse(existingRaw), resolvedProjectPath);
|
||||
const active = existingMission.status === 'active' || existingMission.status === 'paused';
|
||||
if (active && options.force !== true) {
|
||||
throw new Error(
|
||||
`Active mission exists: ${existingMission.name} (${existingMission.status}). Use force to overwrite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await saveMission(mission);
|
||||
|
||||
const manifestPath = toAbsolutePath(resolvedProjectPath, mission.manifestFile);
|
||||
const scratchpadPath = toAbsolutePath(resolvedProjectPath, mission.scratchpadFile);
|
||||
const tasksPath = toAbsolutePath(resolvedProjectPath, mission.tasksFile);
|
||||
|
||||
if (options.force === true || !(await fileExists(manifestPath))) {
|
||||
await writeFileAtomic(manifestPath, renderManifest(mission));
|
||||
}
|
||||
|
||||
if (!(await fileExists(scratchpadPath))) {
|
||||
await writeFileAtomic(scratchpadPath, renderScratchpad(mission));
|
||||
}
|
||||
|
||||
if (!(await fileExists(tasksPath))) {
|
||||
await writeFileAtomic(tasksPath, writeTasksFile([]));
|
||||
}
|
||||
|
||||
return mission;
|
||||
}
|
||||
|
||||
export async function loadMission(projectPath: string): Promise<Mission> {
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
const filePath = missionFilePath(resolvedProjectPath);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||
throw new Error(`No mission found at ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mission = normalizeMission(JSON.parse(raw), resolvedProjectPath);
|
||||
if (mission.status === 'inactive') {
|
||||
throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.');
|
||||
}
|
||||
|
||||
return mission;
|
||||
}
|
||||
464
packages/coord/src/runner.ts
Normal file
464
packages/coord/src/runner.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadMission, saveMission } from './mission.js';
|
||||
import { parseTasksFile, updateTaskStatus } from './tasks-file.js';
|
||||
import type {
|
||||
Mission,
|
||||
MissionMilestone,
|
||||
MissionSession,
|
||||
RunTaskOptions,
|
||||
TaskRun,
|
||||
} from './types.js';
|
||||
|
||||
const SESSION_LOCK_FILE = 'session.lock';
|
||||
const NEXT_TASK_FILE = 'next-task.json';
|
||||
|
||||
interface SessionLockState {
|
||||
session_id: string;
|
||||
runtime: 'claude' | 'codex';
|
||||
pid: number;
|
||||
started_at: string;
|
||||
project_path: string;
|
||||
milestone_id?: string;
|
||||
}
|
||||
|
||||
function orchestratorDirPath(mission: Mission): string {
|
||||
if (path.isAbsolute(mission.orchestratorDir)) {
|
||||
return mission.orchestratorDir;
|
||||
}
|
||||
return path.join(mission.projectPath, mission.orchestratorDir);
|
||||
}
|
||||
|
||||
function sessionLockPath(mission: Mission): string {
|
||||
return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE);
|
||||
}
|
||||
|
||||
function nextTaskCapsulePath(mission: Mission): string {
|
||||
return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE);
|
||||
}
|
||||
|
||||
function tasksFilePath(mission: Mission): string {
|
||||
if (path.isAbsolute(mission.tasksFile)) {
|
||||
return mission.tasksFile;
|
||||
}
|
||||
return path.join(mission.projectPath, mission.tasksFile);
|
||||
}
|
||||
|
||||
function buildSessionId(mission: Mission): string {
|
||||
return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function currentMilestone(mission: Mission): MissionMilestone | undefined {
|
||||
return (
|
||||
mission.milestones.find((milestone) => milestone.status === 'in-progress') ??
|
||||
mission.milestones.find((milestone) => milestone.status === 'pending')
|
||||
);
|
||||
}
|
||||
|
||||
async function readTasks(mission: Mission) {
|
||||
const filePath = tasksFilePath(mission);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return parseTasksFile(content);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === 'ENOENT'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function currentBranch(projectPath: string): string | undefined {
|
||||
const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branch = result.stdout.trim();
|
||||
return branch.length > 0 ? branch : undefined;
|
||||
}
|
||||
|
||||
function percentage(done: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.floor((done / total) * 100);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds: number): string {
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
|
||||
if (totalSeconds < 3600) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function buildContinuationPrompt(params: {
|
||||
mission: Mission;
|
||||
taskId: string;
|
||||
runtime: 'claude' | 'codex';
|
||||
tasksDone: number;
|
||||
tasksTotal: number;
|
||||
currentMilestone?: MissionMilestone;
|
||||
previousSession?: MissionSession;
|
||||
branch?: string;
|
||||
}): string {
|
||||
const {
|
||||
mission,
|
||||
taskId,
|
||||
runtime,
|
||||
tasksDone,
|
||||
tasksTotal,
|
||||
currentMilestone: milestone,
|
||||
previousSession,
|
||||
branch,
|
||||
} = params;
|
||||
|
||||
const pct = percentage(tasksDone, tasksTotal);
|
||||
const previousDuration =
|
||||
previousSession?.durationSeconds !== undefined
|
||||
? formatDurationSeconds(previousSession.durationSeconds)
|
||||
: '—';
|
||||
|
||||
return [
|
||||
'## Continuation Mission',
|
||||
'',
|
||||
`Continue **${mission.name}** from existing state.`,
|
||||
'',
|
||||
'## Setup',
|
||||
'',
|
||||
`- **Project:** ${mission.projectPath}`,
|
||||
`- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`,
|
||||
`- **Manifest:** ${mission.manifestFile}`,
|
||||
`- **Scratchpad:** ${mission.scratchpadFile}`,
|
||||
'- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md',
|
||||
`- **Quality gates:** ${mission.qualityGates ?? '—'}`,
|
||||
`- **Target runtime:** ${runtime}`,
|
||||
'',
|
||||
'## Resume Point',
|
||||
'',
|
||||
`- **Current milestone:** ${milestone?.name ?? '—'} (${milestone?.id ?? '—'})`,
|
||||
`- **Next task:** ${taskId}`,
|
||||
`- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`,
|
||||
`- **Branch:** ${branch ?? '—'}`,
|
||||
'',
|
||||
'## Previous Session Context',
|
||||
'',
|
||||
`- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`,
|
||||
`- **Ended:** ${previousSession?.endedReason ?? '—'}`,
|
||||
`- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`,
|
||||
'',
|
||||
'## Instructions',
|
||||
'',
|
||||
'1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol',
|
||||
`2. Read \`${mission.manifestFile}\` for mission scope and status`,
|
||||
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
||||
`4. Read \`${mission.tasksFile}\` for current task state`,
|
||||
'5. `git pull --rebase` to sync latest changes',
|
||||
`6. Launch runtime with \`${runtime} -p\``,
|
||||
`7. Continue execution from task **${taskId}**`,
|
||||
'8. Follow Two-Phase Completion Protocol',
|
||||
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveLaunchCommand(
|
||||
runtime: 'claude' | 'codex',
|
||||
prompt: string,
|
||||
configuredCommand: string[] | undefined,
|
||||
): string[] {
|
||||
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
||||
return [runtime, '-p', prompt];
|
||||
}
|
||||
|
||||
const withInterpolation = configuredCommand.map((value) =>
|
||||
value === '{prompt}' ? prompt : value,
|
||||
);
|
||||
|
||||
if (withInterpolation.includes(prompt)) {
|
||||
return withInterpolation;
|
||||
}
|
||||
|
||||
return [...withInterpolation, prompt];
|
||||
}
|
||||
|
||||
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
async function readSessionLock(mission: Mission): Promise<SessionLockState | undefined> {
|
||||
const filePath = sessionLockPath(mission);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(raw) as Partial<SessionLockState>;
|
||||
|
||||
if (
|
||||
typeof data.session_id !== 'string' ||
|
||||
(data.runtime !== 'claude' && data.runtime !== 'codex') ||
|
||||
typeof data.pid !== 'number' ||
|
||||
typeof data.started_at !== 'string' ||
|
||||
typeof data.project_path !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
session_id: data.session_id,
|
||||
runtime: data.runtime,
|
||||
pid: data.pid,
|
||||
started_at: data.started_at,
|
||||
project_path: data.project_path,
|
||||
milestone_id: data.milestone_id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === 'ENOENT'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionLock(mission: Mission, lock: SessionLockState): Promise<void> {
|
||||
await writeAtomicJson(sessionLockPath(mission), lock);
|
||||
}
|
||||
|
||||
function markSessionCrashed(mission: Mission, sessionId: string, endedAt: string): Mission {
|
||||
const sessions = mission.sessions.map((session) => {
|
||||
if (session.sessionId !== sessionId) return session;
|
||||
if (session.endedAt !== undefined) return session;
|
||||
|
||||
const startedEpoch = Date.parse(session.startedAt);
|
||||
const endedEpoch = Date.parse(endedAt);
|
||||
const durationSeconds =
|
||||
Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch)
|
||||
? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...session,
|
||||
endedAt,
|
||||
endedReason: 'crashed' as const,
|
||||
durationSeconds,
|
||||
};
|
||||
});
|
||||
|
||||
return { ...mission, sessions };
|
||||
}
|
||||
|
||||
export async function runTask(
|
||||
mission: Mission,
|
||||
taskId: string,
|
||||
options: RunTaskOptions = {},
|
||||
): Promise<TaskRun> {
|
||||
const runtime = options.runtime ?? 'claude';
|
||||
const mode = options.mode ?? 'interactive';
|
||||
|
||||
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 task = matches[0]!;
|
||||
if (task.status === 'done' || task.status === 'cancelled') {
|
||||
throw new Error(`Task ${taskId} cannot be run from status ${task.status}`);
|
||||
}
|
||||
|
||||
const tasksTotal = tasks.length;
|
||||
const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length;
|
||||
const selectedMilestone =
|
||||
freshMission.milestones.find((milestone) => milestone.id === options.milestoneId) ??
|
||||
freshMission.milestones.find((milestone) => milestone.id === task.milestone) ??
|
||||
currentMilestone(freshMission);
|
||||
|
||||
const continuationPrompt = buildContinuationPrompt({
|
||||
mission: freshMission,
|
||||
taskId,
|
||||
runtime,
|
||||
tasksDone,
|
||||
tasksTotal,
|
||||
currentMilestone: selectedMilestone,
|
||||
previousSession: freshMission.sessions.at(-1),
|
||||
branch: currentBranch(freshMission.projectPath),
|
||||
});
|
||||
|
||||
const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command);
|
||||
const startedAt = new Date().toISOString();
|
||||
const sessionId = buildSessionId(freshMission);
|
||||
const lockFile = sessionLockPath(freshMission);
|
||||
|
||||
await writeAtomicJson(nextTaskCapsulePath(freshMission), {
|
||||
generated_at: startedAt,
|
||||
runtime,
|
||||
mission_id: freshMission.id,
|
||||
mission_name: freshMission.name,
|
||||
project_path: freshMission.projectPath,
|
||||
quality_gates: freshMission.qualityGates ?? '',
|
||||
current_milestone: {
|
||||
id: selectedMilestone?.id ?? '',
|
||||
name: selectedMilestone?.name ?? '',
|
||||
},
|
||||
next_task: taskId,
|
||||
progress: {
|
||||
tasks_done: tasksDone,
|
||||
tasks_total: tasksTotal,
|
||||
pct: percentage(tasksDone, tasksTotal),
|
||||
},
|
||||
current_branch: currentBranch(freshMission.projectPath) ?? '',
|
||||
});
|
||||
|
||||
if (mode === 'print-only') {
|
||||
return {
|
||||
missionId: freshMission.id,
|
||||
taskId,
|
||||
sessionId,
|
||||
runtime,
|
||||
launchCommand,
|
||||
startedAt,
|
||||
lockFile,
|
||||
};
|
||||
}
|
||||
|
||||
await updateTaskStatus(freshMission, taskId, 'in-progress');
|
||||
|
||||
await writeSessionLock(freshMission, {
|
||||
session_id: sessionId,
|
||||
runtime,
|
||||
pid: 0,
|
||||
started_at: startedAt,
|
||||
project_path: freshMission.projectPath,
|
||||
milestone_id: selectedMilestone?.id,
|
||||
});
|
||||
|
||||
const child = spawn(launchCommand[0]!, launchCommand.slice(1), {
|
||||
cwd: freshMission.projectPath,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', () => {
|
||||
resolve();
|
||||
});
|
||||
child.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('Failed to start task runtime process (pid missing)');
|
||||
}
|
||||
|
||||
await writeSessionLock(freshMission, {
|
||||
session_id: sessionId,
|
||||
runtime,
|
||||
pid,
|
||||
started_at: startedAt,
|
||||
project_path: freshMission.projectPath,
|
||||
milestone_id: selectedMilestone?.id,
|
||||
});
|
||||
|
||||
const updatedMission: Mission = {
|
||||
...freshMission,
|
||||
status: 'active',
|
||||
sessions: [
|
||||
...freshMission.sessions,
|
||||
{
|
||||
sessionId,
|
||||
runtime,
|
||||
pid,
|
||||
startedAt,
|
||||
milestoneId: selectedMilestone?.id,
|
||||
lastTaskId: taskId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await saveMission(updatedMission);
|
||||
|
||||
return {
|
||||
missionId: updatedMission.id,
|
||||
taskId,
|
||||
sessionId,
|
||||
runtime,
|
||||
launchCommand,
|
||||
startedAt,
|
||||
pid,
|
||||
lockFile,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resumeTask(
|
||||
mission: Mission,
|
||||
taskId: string,
|
||||
options: Omit<RunTaskOptions, 'milestoneId'> = {},
|
||||
): Promise<TaskRun> {
|
||||
const freshMission = await loadMission(mission.projectPath);
|
||||
const lock = await readSessionLock(freshMission);
|
||||
|
||||
if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) {
|
||||
throw new Error(`Session ${lock.session_id} is still running (PID ${lock.pid}).`);
|
||||
}
|
||||
|
||||
let nextMissionState = freshMission;
|
||||
|
||||
if (lock !== undefined) {
|
||||
const endedAt = new Date().toISOString();
|
||||
nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt);
|
||||
await saveMission(nextMissionState);
|
||||
await fs.rm(sessionLockPath(nextMissionState), { force: true });
|
||||
}
|
||||
|
||||
return runTask(nextMissionState, taskId, options);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
184
packages/coord/src/types.ts
Normal file
184
packages/coord/src/types.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
export type TaskStatus = 'not-started' | 'in-progress' | 'done' | 'blocked' | 'cancelled';
|
||||
|
||||
export type MissionStatus = 'active' | 'paused' | 'completed' | 'inactive';
|
||||
|
||||
export type MissionRuntime = 'claude' | 'codex' | 'unknown';
|
||||
|
||||
export interface MissionMilestone {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'blocked';
|
||||
branch?: string;
|
||||
issueRef?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface MissionSession {
|
||||
sessionId: string;
|
||||
runtime: MissionRuntime;
|
||||
pid?: number;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
endedReason?: 'completed' | 'paused' | 'crashed' | 'killed' | 'unknown';
|
||||
milestoneId?: string;
|
||||
lastTaskId?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface Mission {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
projectPath: string;
|
||||
createdAt: string;
|
||||
status: MissionStatus;
|
||||
tasksFile: string;
|
||||
manifestFile: string;
|
||||
scratchpadFile: string;
|
||||
orchestratorDir: string;
|
||||
taskPrefix?: string;
|
||||
qualityGates?: string;
|
||||
milestoneVersion?: string;
|
||||
milestones: MissionMilestone[];
|
||||
sessions: MissionSession[];
|
||||
}
|
||||
|
||||
export interface MissionTask {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
assignee?: string;
|
||||
dependencies: string[];
|
||||
milestone?: string;
|
||||
pr?: string;
|
||||
notes?: string;
|
||||
rawStatus?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface TaskRun {
|
||||
missionId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
runtime: 'claude' | 'codex';
|
||||
launchCommand: string[];
|
||||
startedAt: string;
|
||||
pid?: number;
|
||||
lockFile: string;
|
||||
}
|
||||
|
||||
export interface MissionStatusSummary {
|
||||
mission: Pick<Mission, 'id' | 'name' | 'status' | 'projectPath'>;
|
||||
milestones: {
|
||||
total: number;
|
||||
completed: number;
|
||||
current?: MissionMilestone;
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
done: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
blocked: number;
|
||||
cancelled: number;
|
||||
};
|
||||
nextTaskId?: string;
|
||||
activeSession?: MissionSession;
|
||||
}
|
||||
|
||||
export interface TaskDetail {
|
||||
missionId: string;
|
||||
task: MissionTask;
|
||||
isNextTask: boolean;
|
||||
activeSession?: MissionSession;
|
||||
}
|
||||
|
||||
export interface CreateMissionOptions {
|
||||
name: string;
|
||||
projectPath?: string;
|
||||
prefix?: string;
|
||||
milestones?: string[];
|
||||
qualityGates?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface RunTaskOptions {
|
||||
runtime?: 'claude' | 'codex';
|
||||
mode?: 'interactive' | 'print-only';
|
||||
milestoneId?: string;
|
||||
launchStrategy?: 'subprocess' | 'spawn-adapter';
|
||||
env?: Record<string, string>;
|
||||
command?: string[];
|
||||
}
|
||||
|
||||
export interface NextTaskCapsule {
|
||||
generatedAt: string;
|
||||
runtime: 'claude' | 'codex';
|
||||
missionId: string;
|
||||
missionName: string;
|
||||
projectPath: string;
|
||||
qualityGates?: string;
|
||||
currentMilestone: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
nextTask: string;
|
||||
progress: {
|
||||
tasksDone: number;
|
||||
tasksTotal: number;
|
||||
pct: number;
|
||||
};
|
||||
currentBranch?: string;
|
||||
}
|
||||
|
||||
const LEGACY_TASK_STATUS: Readonly<Record<string, TaskStatus>> = {
|
||||
'not-started': 'not-started',
|
||||
pending: 'not-started',
|
||||
todo: 'not-started',
|
||||
'in-progress': 'in-progress',
|
||||
in_progress: 'in-progress',
|
||||
done: 'done',
|
||||
completed: 'done',
|
||||
blocked: 'blocked',
|
||||
failed: 'blocked',
|
||||
cancelled: 'cancelled',
|
||||
};
|
||||
|
||||
export function normalizeTaskStatus(input: string): {
|
||||
status: TaskStatus;
|
||||
rawStatus?: string;
|
||||
} {
|
||||
const raw = input.trim().toLowerCase();
|
||||
if (raw.length === 0) {
|
||||
return { status: 'not-started' };
|
||||
}
|
||||
|
||||
const normalized = LEGACY_TASK_STATUS[raw];
|
||||
if (normalized === undefined) {
|
||||
return { status: 'not-started', rawStatus: raw };
|
||||
}
|
||||
|
||||
if (raw !== normalized) {
|
||||
return { status: normalized, rawStatus: raw };
|
||||
}
|
||||
|
||||
return { status: normalized };
|
||||
}
|
||||
|
||||
export function isMissionStatus(value: string): value is MissionStatus {
|
||||
return value === 'active' || value === 'paused' || value === 'completed' || value === 'inactive';
|
||||
}
|
||||
|
||||
export function isTaskStatus(value: string): value is TaskStatus {
|
||||
return (
|
||||
value === 'not-started' ||
|
||||
value === 'in-progress' ||
|
||||
value === 'done' ||
|
||||
value === 'blocked' ||
|
||||
value === 'cancelled'
|
||||
);
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
||||
'@mosaic/brain':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/brain
|
||||
'@mosaic/coord':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/coord
|
||||
'@mosaic/db':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/db
|
||||
@@ -255,6 +258,9 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
|
||||
Reference in New Issue
Block a user