diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5cbc6d4e5..fea45e8f0 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -37,6 +37,7 @@ import { createAutomationPlannerService } from "./services/automations/automatio import { createCiService } from "./services/ci/ciService"; import { createRestackSuggestionService } from "./services/lanes/restackSuggestionService"; import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; +import { createMissionService } from "./services/missions/missionService"; function getRendererUrl(): string { const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -413,6 +414,12 @@ app.whenReady().then(async () => { onEvent: (event) => broadcast(IPC.automationsEvent, event) }); + const missionService = createMissionService({ + db, + projectId, + onEvent: (event) => broadcast(IPC.missionsEvent, event) + }); + const automationPlannerService = createAutomationPlannerService({ logger, projectRoot, @@ -535,6 +542,7 @@ app.whenReady().then(async () => { jobEngine, automationService, automationPlannerService, + missionService, ciService, packService, projectConfigService, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 62def3cbe..0c83f9f02 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -21,6 +21,8 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AddMissionArtifactArgs, + AddMissionInterventionArgs, ConflictProposal, ConflictExternalResolverRunSummary, ConflictProposalPreview, @@ -113,6 +115,7 @@ import type { ListOperationsArgs, ListOverlapsArgs, ListLanesArgs, + ListMissionsArgs, ListSessionsArgs, ListTestRunsArgs, MergeSimulationArgs, @@ -175,10 +178,19 @@ import type { TerminalProfilesSnapshot, TerminalSessionSummary, UpdateSessionMetaArgs, + UpdateMissionArgs, + UpdateMissionStepArgs, TestRunSummary, TestSuiteDefinition, UpdateLaneAppearanceArgs, - WriteTextAtomicArgs + WriteTextAtomicArgs, + MissionDetail, + MissionIntervention, + MissionArtifact, + MissionStep, + MissionSummary, + ResolveMissionInterventionArgs, + CreateMissionArgs } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -210,6 +222,7 @@ import type { createOnboardingService } from "../onboarding/onboardingService"; import type { createCiService } from "../ci/ciService"; import type { createAutomationService } from "../automations/automationService"; import type { createAutomationPlannerService } from "../automations/automationPlannerService"; +import type { createMissionService } from "../missions/missionService"; import { redactSecrets } from "../../utils/redaction"; export type AppContext = { @@ -242,6 +255,7 @@ export type AppContext = { jobEngine: ReturnType; automationService: ReturnType; automationPlannerService: ReturnType; + missionService: ReturnType; packService: ReturnType; projectConfigService: ReturnType; processService: ReturnType; @@ -584,6 +598,52 @@ export function registerIpc({ return ctx.automationPlannerService.simulate(arg); }); + ipcMain.handle(IPC.missionsList, async (_event, arg: ListMissionsArgs = {}): Promise => { + const ctx = getCtx(); + return ctx.missionService.list(arg); + }); + + ipcMain.handle(IPC.missionsGet, async (_event, arg: { missionId: string }): Promise => { + const ctx = getCtx(); + return ctx.missionService.get(arg?.missionId ?? ""); + }); + + ipcMain.handle(IPC.missionsCreate, async (_event, arg: CreateMissionArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.create(arg); + }); + + ipcMain.handle(IPC.missionsUpdate, async (_event, arg: UpdateMissionArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.update(arg); + }); + + ipcMain.handle(IPC.missionsUpdateStep, async (_event, arg: UpdateMissionStepArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.updateStep(arg); + }); + + ipcMain.handle(IPC.missionsAddArtifact, async (_event, arg: AddMissionArtifactArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.addArtifact(arg); + }); + + ipcMain.handle( + IPC.missionsAddIntervention, + async (_event, arg: AddMissionInterventionArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.addIntervention(arg); + } + ); + + ipcMain.handle( + IPC.missionsResolveIntervention, + async (_event, arg: ResolveMissionInterventionArgs): Promise => { + const ctx = getCtx(); + return ctx.missionService.resolveIntervention(arg); + } + ); + ipcMain.handle(IPC.layoutGet, async (_event, arg: { layoutId: string }): Promise => { const ctx = getCtx(); const key = `dock_layout:${arg.layoutId}`; diff --git a/apps/desktop/src/main/services/jobs/jobEngine.test.ts b/apps/desktop/src/main/services/jobs/jobEngine.test.ts index 93a1e11b0..66df4e0e5 100644 --- a/apps/desktop/src/main/services/jobs/jobEngine.test.ts +++ b/apps/desktop/src/main/services/jobs/jobEngine.test.ts @@ -64,6 +64,7 @@ describe("jobEngine narrative payload propagation", () => { clipReason: null, omittedSections: [] })), + getPeerLanesContext: vi.fn(async () => null), applyHostedNarrative, recordEvent } as any, diff --git a/apps/desktop/src/main/services/missions/missionService.test.ts b/apps/desktop/src/main/services/missions/missionService.test.ts new file mode 100644 index 000000000..01bc2b82a --- /dev/null +++ b/apps/desktop/src/main/services/missions/missionService.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { createMissionService } from "./missionService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + } as any; +} + +async function createDbWithProjectAndLane() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mission-service-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const projectId = "proj-1"; + const laneId = "lane-1"; + const now = "2026-02-18T00:00:00.000Z"; + + db.run( + ` + insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?) + `, + [projectId, root, "ADE", "main", now, now] + ); + + db.run( + ` + insert into lanes( + id, + project_id, + name, + description, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + is_edit_protected, + parent_lane_id, + color, + icon, + tags_json, + status, + created_at, + archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + laneId, + projectId, + "Lane 1", + null, + "worktree", + "main", + "feature/lane-1", + root, + null, + 0, + null, + null, + null, + null, + "active", + now, + null + ] + ); + + return { + db, + projectId, + laneId, + dispose: () => db.close() + }; +} + +describe("missionService lifecycle", () => { + it("supports valid mission lifecycle transitions", async () => { + const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); + const service = createMissionService({ db, projectId }); + + const created = service.create({ + prompt: "Implement profile tab improvements and prepare PR summary.", + laneId + }); + + expect(created.status).toBe("queued"); + expect(created.steps.length).toBeGreaterThan(0); + + const started = service.update({ missionId: created.id, status: "in_progress" }); + expect(started.status).toBe("in_progress"); + + const intervention = service.addIntervention({ + missionId: created.id, + interventionType: "manual_input", + title: "Need design confirmation", + body: "Confirm whether we should preserve the old spacing scale." + }); + + expect(intervention.status).toBe("open"); + + const paused = service.get(created.id); + expect(paused?.status).toBe("intervention_required"); + expect(paused?.openInterventions).toBe(1); + + service.resolveIntervention({ + missionId: created.id, + interventionId: intervention.id, + status: "resolved", + note: "Proceed with the new spacing scale." + }); + + const resumed = service.get(created.id); + expect(resumed?.status).toBe("in_progress"); + expect(resumed?.openInterventions).toBe(0); + + const completed = service.update({ + missionId: created.id, + status: "completed", + outcomeSummary: "Updated profile layout and linked PR #123." + }); + + expect(completed.status).toBe("completed"); + expect(completed.outcomeSummary).toContain("PR #123"); + expect(completed.artifacts.some((artifact) => artifact.artifactType === "summary")).toBe(true); + + dispose(); + }); + + it("rejects invalid mission and step transitions", async () => { + const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); + const service = createMissionService({ db, projectId }); + + const created = service.create({ + prompt: "Write migration notes and close release lane.", + laneId + }); + + expect(() => service.update({ missionId: created.id, status: "completed" })).toThrow(/Invalid mission transition/i); + + const firstStep = service.get(created.id)?.steps[0]; + expect(firstStep).toBeTruthy(); + if (!firstStep) { + dispose(); + throw new Error("Expected first step"); + } + + expect(() => + service.updateStep({ + missionId: created.id, + stepId: firstStep.id, + status: "succeeded" + }) + ).toThrow(/Invalid mission step transition/i); + + dispose(); + }); + + it("creates intervention-required status when a running step fails", async () => { + const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); + const service = createMissionService({ db, projectId }); + + const created = service.create({ + prompt: "Refactor auth checks and run regression tests.", + laneId + }); + + service.update({ missionId: created.id, status: "in_progress" }); + + const firstStep = service.get(created.id)?.steps[0]; + expect(firstStep).toBeTruthy(); + if (!firstStep) { + dispose(); + throw new Error("Expected first step"); + } + + service.updateStep({ + missionId: created.id, + stepId: firstStep.id, + status: "running" + }); + + service.updateStep({ + missionId: created.id, + stepId: firstStep.id, + status: "failed", + note: "Unit tests failed in CI parity suite." + }); + + const detail = service.get(created.id); + expect(detail?.status).toBe("intervention_required"); + expect(detail?.openInterventions).toBeGreaterThan(0); + expect(detail?.lastError).toContain("Unit tests failed"); + + dispose(); + }); +}); diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts new file mode 100644 index 000000000..e15c74d1e --- /dev/null +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -0,0 +1,1467 @@ +import { randomUUID } from "node:crypto"; +import type { + AddMissionArtifactArgs, + AddMissionInterventionArgs, + CreateMissionArgs, + ListMissionsArgs, + MissionArtifact, + MissionArtifactType, + MissionDetail, + MissionEvent, + MissionExecutionMode, + MissionIntervention, + MissionInterventionStatus, + MissionInterventionType, + MissionPriority, + MissionsEventPayload, + MissionStatus, + MissionStep, + MissionStepStatus, + MissionSummary, + ResolveMissionInterventionArgs, + UpdateMissionArgs, + UpdateMissionStepArgs +} from "../../../shared/types"; +import type { AdeDb } from "../state/kvDb"; + +const TERMINAL_MISSION_STATUSES = new Set(["completed", "failed", "canceled"]); + +const MISSION_TRANSITIONS: Record> = { + queued: new Set(["queued", "in_progress", "canceled"]), + in_progress: new Set(["in_progress", "intervention_required", "completed", "failed", "canceled"]), + intervention_required: new Set(["intervention_required", "in_progress", "failed", "canceled"]), + completed: new Set(["completed", "queued"]), + failed: new Set(["failed", "queued", "in_progress", "canceled"]), + canceled: new Set(["canceled", "queued", "in_progress"]) +}; + +const STEP_TRANSITIONS: Record> = { + pending: new Set(["pending", "running", "skipped", "blocked", "canceled"]), + running: new Set(["running", "succeeded", "failed", "blocked", "canceled"]), + blocked: new Set(["blocked", "running", "failed", "canceled", "skipped"]), + succeeded: new Set(["succeeded"]), + failed: new Set(["failed", "running", "canceled"]), + skipped: new Set(["skipped"]), + canceled: new Set(["canceled"]) +}; + +type MissionRow = { + id: string; + title: string; + prompt: string; + lane_id: string | null; + lane_name: string | null; + status: string; + priority: string; + execution_mode: string; + target_machine_id: string | null; + outcome_summary: string | null; + last_error: string | null; + artifact_count: number; + open_interventions: number; + total_steps: number; + completed_steps: number; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; +}; + +type MissionStepRow = { + id: string; + mission_id: string; + step_index: number; + title: string; + detail: string | null; + kind: string; + lane_id: string | null; + status: string; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; + metadata_json: string | null; +}; + +type MissionEventRow = { + id: string; + mission_id: string; + event_type: string; + actor: string; + summary: string; + payload_json: string | null; + created_at: string; +}; + +type MissionArtifactRow = { + id: string; + mission_id: string; + artifact_type: string; + title: string; + description: string | null; + uri: string | null; + lane_id: string | null; + created_by: string; + created_at: string; + updated_at: string; + metadata_json: string | null; +}; + +type MissionInterventionRow = { + id: string; + mission_id: string; + intervention_type: string; + status: string; + title: string; + body: string; + requested_action: string | null; + resolution_note: string | null; + lane_id: string | null; + created_at: string; + updated_at: string; + resolved_at: string | null; + metadata_json: string | null; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function safeParseRecord(raw: string | null): Record | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function normalizeMissionStatus(value: string): MissionStatus { + if ( + value === "queued" || + value === "in_progress" || + value === "intervention_required" || + value === "completed" || + value === "failed" || + value === "canceled" + ) { + return value; + } + return "queued"; +} + +function normalizeMissionPriority(value: string): MissionPriority { + if (value === "urgent" || value === "high" || value === "normal" || value === "low") return value; + return "normal"; +} + +function normalizeExecutionMode(value: string): MissionExecutionMode { + if (value === "local" || value === "relay") return value; + return "local"; +} + +function normalizeStepStatus(value: string): MissionStepStatus { + if ( + value === "pending" || + value === "running" || + value === "succeeded" || + value === "failed" || + value === "skipped" || + value === "blocked" || + value === "canceled" + ) { + return value; + } + return "pending"; +} + +function normalizeArtifactType(value: string): MissionArtifactType { + if (value === "summary" || value === "pr" || value === "link" || value === "note" || value === "patch") return value; + return "note"; +} + +function normalizeInterventionType(value: string): MissionInterventionType { + if (value === "approval_required" || value === "manual_input" || value === "conflict" || value === "policy_block" || value === "failed_step") { + return value; + } + return "manual_input"; +} + +function normalizeInterventionStatus(value: string): MissionInterventionStatus { + if (value === "open" || value === "resolved" || value === "dismissed") return value; + return "open"; +} + +function normalizePrompt(prompt: string): string { + return prompt + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function summarizePrompt(prompt: string): string { + const oneLine = prompt.replace(/\s+/g, " ").trim(); + if (!oneLine.length) return "Mission"; + if (oneLine.length <= 88) return oneLine; + return `${oneLine.slice(0, 85)}...`; +} + +function deriveMissionTitle(prompt: string, explicit?: string): string { + const cleanedExplicit = (explicit ?? "").trim(); + if (cleanedExplicit.length) return cleanedExplicit.slice(0, 140); + const firstSentence = normalizePrompt(prompt).split(/(?<=[.!?])\s+/)[0] ?? ""; + const compact = firstSentence.trim() || summarizePrompt(prompt); + return compact.slice(0, 140); +} + +function sanitizeOptionalText(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function coerceNullableString(value: unknown): string | null { + if (value == null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function buildInitialStepTitles(prompt: string): string[] { + const lines = normalizePrompt(prompt) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const extracted: string[] = []; + for (const line of lines) { + const bulletMatch = line.match(/^(?:[-*•]|\d+[.)])\s+(.+)$/); + if (!bulletMatch) continue; + const value = bulletMatch[1]?.trim(); + if (!value) continue; + extracted.push(value.slice(0, 120)); + if (extracted.length >= 8) break; + } + + if (extracted.length >= 2) return extracted; + + return [ + "Review mission objective", + "Prepare approach", + "Execute changes", + "Capture outcomes" + ]; +} + +function toMissionSummary(row: MissionRow): MissionSummary { + return { + id: row.id, + title: row.title, + prompt: row.prompt, + laneId: row.lane_id, + laneName: row.lane_name, + status: normalizeMissionStatus(row.status), + priority: normalizeMissionPriority(row.priority), + executionMode: normalizeExecutionMode(row.execution_mode), + targetMachineId: row.target_machine_id, + outcomeSummary: row.outcome_summary, + lastError: row.last_error, + artifactCount: Number(row.artifact_count ?? 0), + openInterventions: Number(row.open_interventions ?? 0), + totalSteps: Number(row.total_steps ?? 0), + completedSteps: Number(row.completed_steps ?? 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + startedAt: row.started_at, + completedAt: row.completed_at + }; +} + +function toMissionStep(row: MissionStepRow): MissionStep { + return { + id: row.id, + missionId: row.mission_id, + index: Number(row.step_index ?? 0), + title: row.title, + detail: row.detail, + kind: row.kind, + laneId: row.lane_id, + status: normalizeStepStatus(row.status), + createdAt: row.created_at, + updatedAt: row.updated_at, + startedAt: row.started_at, + completedAt: row.completed_at, + metadata: safeParseRecord(row.metadata_json) + }; +} + +function toMissionEvent(row: MissionEventRow): MissionEvent { + return { + id: row.id, + missionId: row.mission_id, + eventType: row.event_type, + actor: row.actor, + summary: row.summary, + payload: safeParseRecord(row.payload_json), + createdAt: row.created_at + }; +} + +function toMissionArtifact(row: MissionArtifactRow): MissionArtifact { + return { + id: row.id, + missionId: row.mission_id, + artifactType: normalizeArtifactType(row.artifact_type), + title: row.title, + description: row.description, + uri: row.uri, + laneId: row.lane_id, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + metadata: safeParseRecord(row.metadata_json) + }; +} + +function toMissionIntervention(row: MissionInterventionRow): MissionIntervention { + return { + id: row.id, + missionId: row.mission_id, + interventionType: normalizeInterventionType(row.intervention_type), + status: normalizeInterventionStatus(row.status), + title: row.title, + body: row.body, + requestedAction: row.requested_action, + resolutionNote: row.resolution_note, + laneId: row.lane_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + resolvedAt: row.resolved_at, + metadata: safeParseRecord(row.metadata_json) + }; +} + +function hasTransition( + graph: Record>, + from: MissionStatus, + to: MissionStatus +): boolean { + return graph[from]?.has(to) ?? false; +} + +export function isValidMissionTransition(from: MissionStatus, to: MissionStatus): boolean { + return hasTransition(MISSION_TRANSITIONS, from, to); +} + +export function isValidMissionStepTransition(from: MissionStepStatus, to: MissionStepStatus): boolean { + return STEP_TRANSITIONS[from]?.has(to) ?? false; +} + +export function createMissionService({ + db, + projectId, + onEvent +}: { + db: AdeDb; + projectId: string; + onEvent?: (payload: MissionsEventPayload) => void; +}) { + const emit = (payload: Omit) => { + try { + onEvent?.({ + type: "missions-updated", + at: nowIso(), + ...payload + }); + } catch { + // Ignore broadcast failures. + } + }; + + const assertLaneExists = (laneId: string | null | undefined) => { + if (!laneId) return; + const hit = db.get<{ id: string }>( + "select id from lanes where id = ? and project_id = ? and status != 'archived' limit 1", + [laneId, projectId] + ); + if (!hit?.id) { + throw new Error(`Lane not found or archived: ${laneId}`); + } + }; + + const baseMissionSelect = ` + select + m.id as id, + m.title as title, + m.prompt as prompt, + m.lane_id as lane_id, + l.name as lane_name, + m.status as status, + m.priority as priority, + m.execution_mode as execution_mode, + m.target_machine_id as target_machine_id, + m.outcome_summary as outcome_summary, + m.last_error as last_error, + ( + select count(*) + from mission_artifacts ma + where ma.project_id = m.project_id and ma.mission_id = m.id + ) as artifact_count, + ( + select count(*) + from mission_interventions mi + where mi.project_id = m.project_id and mi.mission_id = m.id and mi.status = 'open' + ) as open_interventions, + ( + select count(*) + from mission_steps ms + where ms.project_id = m.project_id and ms.mission_id = m.id + ) as total_steps, + ( + select count(*) + from mission_steps ms + where ms.project_id = m.project_id and ms.mission_id = m.id and ms.status in ('succeeded', 'skipped') + ) as completed_steps, + m.created_at as created_at, + m.updated_at as updated_at, + m.started_at as started_at, + m.completed_at as completed_at + from missions m + left join lanes l on l.id = m.lane_id + where m.project_id = ? + `; + + const getMissionRow = (missionId: string): MissionRow | null => { + return db.get( + `${baseMissionSelect} + and m.id = ? + limit 1`, + [projectId, missionId] + ); + }; + + const recordEvent = (args: { + missionId: string; + eventType: string; + actor: string; + summary: string; + payload?: Record | null; + }): MissionEvent => { + const id = randomUUID(); + const createdAt = nowIso(); + db.run( + ` + insert into mission_events( + id, + mission_id, + project_id, + event_type, + actor, + summary, + payload_json, + created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + args.missionId, + projectId, + args.eventType, + args.actor, + args.summary, + args.payload ? JSON.stringify(args.payload) : null, + createdAt + ] + ); + return { + id, + missionId: args.missionId, + eventType: args.eventType, + actor: args.actor, + summary: args.summary, + payload: args.payload ?? null, + createdAt + }; + }; + + const upsertMissionStatus = (args: { + missionId: string; + nextStatus: MissionStatus; + updatedAt?: string; + summary?: string; + payload?: Record; + actor?: string; + }) => { + const row = db.get<{ + status: string; + started_at: string | null; + completed_at: string | null; + }>( + "select status, started_at, completed_at from missions where id = ? and project_id = ? limit 1", + [args.missionId, projectId] + ); + if (!row) throw new Error(`Mission not found: ${args.missionId}`); + + const previous = normalizeMissionStatus(row.status); + const next = args.nextStatus; + if (!isValidMissionTransition(previous, next)) { + throw new Error(`Invalid mission transition: ${previous} -> ${next}`); + } + + const updatedAt = args.updatedAt ?? nowIso(); + let startedAt = row.started_at; + let completedAt = row.completed_at; + + if (next === "in_progress") { + if (!startedAt) startedAt = updatedAt; + completedAt = null; + } else if (next === "queued") { + startedAt = null; + completedAt = null; + } else if (TERMINAL_MISSION_STATUSES.has(next)) { + completedAt = updatedAt; + if (!startedAt) startedAt = updatedAt; + } + + db.run( + ` + update missions + set status = ?, + started_at = ?, + completed_at = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [next, startedAt, completedAt, updatedAt, args.missionId, projectId] + ); + + if (previous !== next) { + recordEvent({ + missionId: args.missionId, + eventType: "mission_status_changed", + actor: args.actor ?? "user", + summary: args.summary ?? `Mission status changed to ${next}.`, + payload: { + from: previous, + to: next, + ...(args.payload ?? {}) + } + }); + } + }; + + const insertArtifact = (args: { + missionId: string; + artifactType: MissionArtifactType; + title: string; + description?: string | null; + uri?: string | null; + laneId?: string | null; + createdBy: string; + metadata?: Record | null; + }): MissionArtifact => { + assertLaneExists(args.laneId ?? null); + + const id = randomUUID(); + const createdAt = nowIso(); + const title = args.title.trim(); + if (!title.length) throw new Error("Artifact title is required"); + + const description = sanitizeOptionalText(args.description ?? null); + const uri = coerceNullableString(args.uri); + + db.run( + ` + insert into mission_artifacts( + id, + mission_id, + project_id, + artifact_type, + title, + description, + uri, + lane_id, + metadata_json, + created_at, + updated_at, + created_by + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + args.missionId, + projectId, + args.artifactType, + title, + description, + uri, + args.laneId ?? null, + args.metadata ? JSON.stringify(args.metadata) : null, + createdAt, + createdAt, + args.createdBy + ] + ); + + return { + id, + missionId: args.missionId, + artifactType: args.artifactType, + title, + description, + uri, + laneId: args.laneId ?? null, + createdBy: args.createdBy, + createdAt, + updatedAt: createdAt, + metadata: args.metadata ?? null + }; + }; + + const insertIntervention = (args: { + missionId: string; + interventionType: MissionInterventionType; + title: string; + body: string; + requestedAction?: string | null; + laneId?: string | null; + metadata?: Record | null; + }): MissionIntervention => { + assertLaneExists(args.laneId ?? null); + + const id = randomUUID(); + const createdAt = nowIso(); + const title = args.title.trim(); + const body = args.body.trim(); + if (!title.length) throw new Error("Intervention title is required"); + if (!body.length) throw new Error("Intervention body is required"); + + db.run( + ` + insert into mission_interventions( + id, + mission_id, + project_id, + intervention_type, + status, + title, + body, + requested_action, + resolution_note, + lane_id, + metadata_json, + created_at, + updated_at, + resolved_at + ) values (?, ?, ?, ?, 'open', ?, ?, ?, null, ?, ?, ?, ?, null) + `, + [ + id, + args.missionId, + projectId, + args.interventionType, + title, + body, + sanitizeOptionalText(args.requestedAction ?? null), + args.laneId ?? null, + args.metadata ? JSON.stringify(args.metadata) : null, + createdAt, + createdAt + ] + ); + + return { + id, + missionId: args.missionId, + interventionType: args.interventionType, + status: "open", + title, + body, + requestedAction: sanitizeOptionalText(args.requestedAction ?? null), + resolutionNote: null, + laneId: args.laneId ?? null, + createdAt, + updatedAt: createdAt, + resolvedAt: null, + metadata: args.metadata ?? null + }; + }; + + return { + list(args: ListMissionsArgs = {}): MissionSummary[] { + const where: string[] = []; + const params: Array = [projectId]; + + const laneId = typeof args.laneId === "string" ? args.laneId.trim() : ""; + if (laneId.length) { + where.push("m.lane_id = ?"); + params.push(laneId); + } + + if (args.status === "active") { + where.push("m.status in ('queued', 'in_progress', 'intervention_required')"); + } else if (args.status) { + where.push("m.status = ?"); + params.push(args.status); + } + + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 120))) : 120; + + const rows = db.all( + `${baseMissionSelect} + ${where.length ? `and ${where.join(" and ")}` : ""} + order by + case m.status + when 'intervention_required' then 0 + when 'in_progress' then 1 + when 'queued' then 2 + when 'failed' then 3 + when 'completed' then 4 + else 5 + end, + m.updated_at desc, + m.created_at desc + limit ?`, + [...params, limit] + ); + + return rows.map(toMissionSummary); + }, + + get(missionId: string): MissionDetail | null { + const id = missionId.trim(); + if (!id.length) return null; + + const row = getMissionRow(id); + if (!row) return null; + + const steps = db + .all( + ` + select + id, + mission_id, + step_index, + title, + detail, + kind, + lane_id, + status, + created_at, + updated_at, + started_at, + completed_at, + metadata_json + from mission_steps + where project_id = ? + and mission_id = ? + order by step_index asc + `, + [projectId, id] + ) + .map(toMissionStep); + + const events = db + .all( + ` + select + id, + mission_id, + event_type, + actor, + summary, + payload_json, + created_at + from mission_events + where project_id = ? + and mission_id = ? + order by created_at desc + limit 500 + `, + [projectId, id] + ) + .map(toMissionEvent); + + const artifacts = db + .all( + ` + select + id, + mission_id, + artifact_type, + title, + description, + uri, + lane_id, + created_by, + created_at, + updated_at, + metadata_json + from mission_artifacts + where project_id = ? + and mission_id = ? + order by created_at desc + `, + [projectId, id] + ) + .map(toMissionArtifact); + + const interventions = db + .all( + ` + select + id, + mission_id, + intervention_type, + status, + title, + body, + requested_action, + resolution_note, + lane_id, + created_at, + updated_at, + resolved_at, + metadata_json + from mission_interventions + where project_id = ? + and mission_id = ? + order by + case status when 'open' then 0 when 'resolved' then 1 else 2 end, + created_at desc + `, + [projectId, id] + ) + .map(toMissionIntervention); + + return { + ...toMissionSummary(row), + steps, + events, + artifacts, + interventions + }; + }, + + create(args: CreateMissionArgs): MissionDetail { + const prompt = normalizePrompt(args.prompt ?? ""); + if (!prompt.length) { + throw new Error("Mission prompt is required."); + } + + const title = deriveMissionTitle(prompt, args.title); + const laneId = coerceNullableString(args.laneId); + assertLaneExists(laneId); + const priority = args.priority ?? "normal"; + const executionMode = args.executionMode ?? "local"; + const targetMachineId = coerceNullableString(args.targetMachineId); + + const id = randomUUID(); + const createdAt = nowIso(); + + db.run( + ` + insert into missions( + id, + project_id, + lane_id, + title, + prompt, + status, + priority, + execution_mode, + target_machine_id, + outcome_summary, + last_error, + metadata_json, + created_at, + updated_at, + started_at, + completed_at + ) values (?, ?, ?, ?, ?, 'queued', ?, ?, ?, null, null, ?, ?, ?, null, null) + `, + [ + id, + projectId, + laneId, + title, + prompt, + priority, + executionMode, + targetMachineId, + JSON.stringify({ source: "manual", version: 1 }), + createdAt, + createdAt + ] + ); + + const stepTitles = buildInitialStepTitles(prompt); + stepTitles.forEach((stepTitle, index) => { + const stepId = randomUUID(); + db.run( + ` + insert into mission_steps( + id, + mission_id, + project_id, + step_index, + title, + detail, + kind, + lane_id, + status, + metadata_json, + created_at, + updated_at, + started_at, + completed_at + ) values (?, ?, ?, ?, ?, null, 'placeholder', ?, 'pending', null, ?, ?, null, null) + `, + [stepId, id, projectId, index, stepTitle, laneId, createdAt, createdAt] + ); + }); + + recordEvent({ + missionId: id, + eventType: "mission_created", + actor: "user", + summary: "Mission created from plain-English prompt.", + payload: { + title, + laneId, + priority, + executionMode, + targetMachineId, + preview: summarizePrompt(prompt) + } + }); + + emit({ missionId: id, reason: "created" }); + const detail = this.get(id); + if (!detail) throw new Error("Mission creation failed"); + return detail; + }, + + update(args: UpdateMissionArgs): MissionDetail { + const missionId = args.missionId.trim(); + if (!missionId.length) throw new Error("Mission id is required."); + + const existing = db.get<{ + id: string; + title: string; + prompt: string; + lane_id: string | null; + status: string; + priority: string; + execution_mode: string; + target_machine_id: string | null; + outcome_summary: string | null; + last_error: string | null; + }>( + ` + select + id, + title, + prompt, + lane_id, + status, + priority, + execution_mode, + target_machine_id, + outcome_summary, + last_error + from missions + where id = ? + and project_id = ? + limit 1 + `, + [missionId, projectId] + ); + + if (!existing) { + throw new Error(`Mission not found: ${missionId}`); + } + + const nextLaneId = args.laneId !== undefined ? coerceNullableString(args.laneId) : existing.lane_id; + assertLaneExists(nextLaneId); + + const nextPrompt = args.prompt !== undefined ? normalizePrompt(args.prompt) : existing.prompt; + if (!nextPrompt.length) throw new Error("Mission prompt cannot be empty."); + const nextTitle = args.title !== undefined ? deriveMissionTitle(nextPrompt, args.title) : existing.title; + + const nextPriority = args.priority ?? normalizeMissionPriority(existing.priority); + const nextExecutionMode = args.executionMode ?? normalizeExecutionMode(existing.execution_mode); + const nextTargetMachineId = + args.targetMachineId !== undefined ? coerceNullableString(args.targetMachineId) : existing.target_machine_id; + const nextOutcomeSummary = + args.outcomeSummary !== undefined ? sanitizeOptionalText(args.outcomeSummary) : existing.outcome_summary; + const nextLastError = args.lastError !== undefined ? sanitizeOptionalText(args.lastError) : existing.last_error; + + const updatedAt = nowIso(); + + if (args.status) { + upsertMissionStatus({ + missionId, + nextStatus: args.status, + updatedAt, + summary: `Mission status changed to ${args.status}.` + }); + } + + db.run( + ` + update missions + set title = ?, + prompt = ?, + lane_id = ?, + priority = ?, + execution_mode = ?, + target_machine_id = ?, + outcome_summary = ?, + last_error = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [ + nextTitle, + nextPrompt, + nextLaneId, + nextPriority, + nextExecutionMode, + nextTargetMachineId, + nextOutcomeSummary, + nextLastError, + updatedAt, + missionId, + projectId + ] + ); + + const changedFields: string[] = []; + if (nextTitle !== existing.title) changedFields.push("title"); + if (nextPrompt !== existing.prompt) changedFields.push("prompt"); + if (nextLaneId !== existing.lane_id) changedFields.push("laneId"); + if (nextPriority !== existing.priority) changedFields.push("priority"); + if (nextExecutionMode !== existing.execution_mode) changedFields.push("executionMode"); + if (nextTargetMachineId !== existing.target_machine_id) changedFields.push("targetMachineId"); + if (nextOutcomeSummary !== existing.outcome_summary) changedFields.push("outcomeSummary"); + if (nextLastError !== existing.last_error) changedFields.push("lastError"); + if (changedFields.length) { + recordEvent({ + missionId, + eventType: "mission_updated", + actor: "user", + summary: `Mission updated (${changedFields.join(", ")}).`, + payload: { changedFields } + }); + } + + if (nextOutcomeSummary && args.outcomeSummary !== undefined) { + const hasSummaryArtifact = db.get<{ id: string }>( + ` + select id + from mission_artifacts + where project_id = ? + and mission_id = ? + and artifact_type = 'summary' + order by created_at desc + limit 1 + `, + [projectId, missionId] + ); + + if (!hasSummaryArtifact?.id) { + const summaryArtifact = insertArtifact({ + missionId, + artifactType: "summary", + title: "Mission outcome summary", + description: nextOutcomeSummary, + createdBy: "system" + }); + recordEvent({ + missionId, + eventType: "mission_artifact_added", + actor: "system", + summary: "Outcome summary artifact recorded.", + payload: { + artifactId: summaryArtifact.id, + artifactType: summaryArtifact.artifactType + } + }); + } + } + + emit({ missionId, reason: "updated" }); + const detail = this.get(missionId); + if (!detail) throw new Error("Mission update failed"); + return detail; + }, + + updateStep(args: UpdateMissionStepArgs): MissionStep { + const missionId = args.missionId.trim(); + const stepId = args.stepId.trim(); + if (!missionId.length || !stepId.length) throw new Error("missionId and stepId are required."); + + const step = db.get( + ` + select + id, + mission_id, + step_index, + title, + detail, + kind, + lane_id, + status, + created_at, + updated_at, + started_at, + completed_at, + metadata_json + from mission_steps + where id = ? + and mission_id = ? + and project_id = ? + limit 1 + `, + [stepId, missionId, projectId] + ); + + if (!step) { + throw new Error(`Mission step not found: ${stepId}`); + } + + const previous = normalizeStepStatus(step.status); + const next = args.status; + if (!isValidMissionStepTransition(previous, next)) { + throw new Error(`Invalid mission step transition: ${previous} -> ${next}`); + } + + const updatedAt = nowIso(); + let startedAt = step.started_at; + let completedAt = step.completed_at; + + if (next === "running") { + if (!startedAt) startedAt = updatedAt; + completedAt = null; + } + + if (next === "pending") { + startedAt = null; + completedAt = null; + } + + if (next === "succeeded" || next === "failed" || next === "skipped" || next === "canceled") { + if (!startedAt) startedAt = updatedAt; + completedAt = updatedAt; + } + + if (next === "blocked") { + completedAt = null; + } + + db.run( + ` + update mission_steps + set status = ?, + started_at = ?, + completed_at = ?, + updated_at = ? + where id = ? + and mission_id = ? + and project_id = ? + `, + [next, startedAt, completedAt, updatedAt, stepId, missionId, projectId] + ); + + const note = sanitizeOptionalText(args.note ?? null); + recordEvent({ + missionId, + eventType: "mission_step_updated", + actor: "user", + summary: `Step ${Number(step.step_index) + 1} set to ${next}.`, + payload: { + stepId, + stepIndex: Number(step.step_index), + stepTitle: step.title, + from: previous, + to: next, + ...(note ? { note } : {}) + } + }); + + if (next === "failed") { + const intervention = insertIntervention({ + missionId, + interventionType: "failed_step", + title: `Step failed: ${step.title}`, + body: note ?? "A mission step was marked as failed and needs attention.", + requestedAction: "Review the failure and decide whether to continue, retry, or cancel." + }); + + db.run( + ` + update missions + set last_error = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [note ?? step.title, updatedAt, missionId, projectId] + ); + + upsertMissionStatus({ + missionId, + nextStatus: "intervention_required", + updatedAt, + summary: "Mission paused for intervention after step failure.", + payload: { + interventionId: intervention.id, + stepId + } + }); + } + + emit({ missionId, reason: "step-updated" }); + + const nextStep = db.get( + ` + select + id, + mission_id, + step_index, + title, + detail, + kind, + lane_id, + status, + created_at, + updated_at, + started_at, + completed_at, + metadata_json + from mission_steps + where id = ? + and mission_id = ? + and project_id = ? + limit 1 + `, + [stepId, missionId, projectId] + ); + + if (!nextStep) throw new Error("Mission step update failed"); + return toMissionStep(nextStep); + }, + + addArtifact(args: AddMissionArtifactArgs): MissionArtifact { + const missionId = args.missionId.trim(); + if (!missionId.length) throw new Error("missionId is required."); + if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); + + const artifact = insertArtifact({ + missionId, + artifactType: args.artifactType, + title: args.title, + description: args.description, + uri: args.uri, + laneId: args.laneId, + metadata: args.metadata, + createdBy: "user" + }); + + recordEvent({ + missionId, + eventType: "mission_artifact_added", + actor: "user", + summary: `Artifact added: ${artifact.title}`, + payload: { + artifactId: artifact.id, + artifactType: artifact.artifactType, + uri: artifact.uri + } + }); + + db.run( + "update missions set updated_at = ? where id = ? and project_id = ?", + [nowIso(), missionId, projectId] + ); + emit({ missionId, reason: "artifact-added" }); + return artifact; + }, + + addIntervention(args: AddMissionInterventionArgs): MissionIntervention { + const missionId = args.missionId.trim(); + if (!missionId.length) throw new Error("missionId is required."); + if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); + + const intervention = insertIntervention({ + missionId, + interventionType: args.interventionType, + title: args.title, + body: args.body, + requestedAction: args.requestedAction, + laneId: args.laneId, + metadata: args.metadata + }); + + recordEvent({ + missionId, + eventType: "mission_intervention_added", + actor: "user", + summary: `Intervention added: ${intervention.title}`, + payload: { + interventionId: intervention.id, + interventionType: intervention.interventionType + } + }); + + upsertMissionStatus({ + missionId, + nextStatus: "intervention_required", + summary: "Mission moved to intervention required." + }); + + db.run( + "update missions set updated_at = ? where id = ? and project_id = ?", + [nowIso(), missionId, projectId] + ); + emit({ missionId, reason: "intervention-added" }); + return intervention; + }, + + resolveIntervention(args: ResolveMissionInterventionArgs): MissionIntervention { + const missionId = args.missionId.trim(); + const interventionId = args.interventionId.trim(); + if (!missionId.length || !interventionId.length) { + throw new Error("missionId and interventionId are required."); + } + + const row = db.get( + ` + select + id, + mission_id, + intervention_type, + status, + title, + body, + requested_action, + resolution_note, + lane_id, + created_at, + updated_at, + resolved_at, + metadata_json + from mission_interventions + where id = ? + and mission_id = ? + and project_id = ? + limit 1 + `, + [interventionId, missionId, projectId] + ); + + if (!row) { + throw new Error(`Intervention not found: ${interventionId}`); + } + + const targetStatus = args.status; + const note = sanitizeOptionalText(args.note ?? null); + const resolvedAt = nowIso(); + + db.run( + ` + update mission_interventions + set status = ?, + resolution_note = ?, + resolved_at = ?, + updated_at = ? + where id = ? + and mission_id = ? + and project_id = ? + `, + [targetStatus, note, resolvedAt, resolvedAt, interventionId, missionId, projectId] + ); + + recordEvent({ + missionId, + eventType: "mission_intervention_resolved", + actor: "user", + summary: `Intervention ${targetStatus}: ${row.title}`, + payload: { + interventionId, + status: targetStatus, + ...(note ? { note } : {}) + } + }); + + const openCount = db.get<{ count: number }>( + ` + select count(*) as count + from mission_interventions + where project_id = ? + and mission_id = ? + and status = 'open' + `, + [projectId, missionId] + ); + + if ((openCount?.count ?? 0) === 0) { + const mission = db.get<{ status: string }>( + "select status from missions where id = ? and project_id = ? limit 1", + [missionId, projectId] + ); + if (mission && normalizeMissionStatus(mission.status) === "intervention_required") { + upsertMissionStatus({ + missionId, + nextStatus: "in_progress", + summary: "All interventions resolved. Mission resumed." + }); + } + } + + db.run( + "update missions set updated_at = ? where id = ? and project_id = ?", + [resolvedAt, missionId, projectId] + ); + emit({ missionId, reason: "intervention-resolved" }); + + const updated = db.get( + ` + select + id, + mission_id, + intervention_type, + status, + title, + body, + requested_action, + resolution_note, + lane_id, + created_at, + updated_at, + resolved_at, + metadata_json + from mission_interventions + where id = ? + and mission_id = ? + and project_id = ? + limit 1 + `, + [interventionId, missionId, projectId] + ); + if (!updated) throw new Error("Intervention update failed"); + return toMissionIntervention(updated); + } + }; +} diff --git a/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts new file mode 100644 index 000000000..2bfa61355 --- /dev/null +++ b/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { openKvDb } from "./kvDb"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + } as any; +} + +function listColumnNames(db: Awaited>, table: string): string[] { + const rows = db.all<{ name: string }>(`pragma table_info(${table})`); + return rows.map((row) => String(row.name ?? "")).filter(Boolean); +} + +describe("kvDb mission schema migration", () => { + it("creates mission tables and key indexes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-missions-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const expectedTables = [ + "missions", + "mission_steps", + "mission_events", + "mission_artifacts", + "mission_interventions" + ]; + + for (const table of expectedTables) { + const hit = db.get<{ name: string }>( + "select name from sqlite_master where type = 'table' and name = ? limit 1", + [table] + ); + expect(hit?.name).toBe(table); + } + + expect(listColumnNames(db, "missions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "lane_id", + "title", + "prompt", + "status", + "priority", + "execution_mode", + "target_machine_id", + "outcome_summary", + "last_error", + "metadata_json", + "created_at", + "updated_at", + "started_at", + "completed_at" + ]) + ); + + expect(listColumnNames(db, "mission_steps")).toEqual( + expect.arrayContaining(["mission_id", "step_index", "status", "started_at", "completed_at"]) + ); + + const expectedIndexes = [ + "idx_missions_project_updated", + "idx_mission_steps_mission_index", + "idx_mission_events_mission_created", + "idx_mission_artifacts_mission_created", + "idx_mission_interventions_mission_status" + ]; + + for (const indexName of expectedIndexes) { + const hit = db.get<{ name: string }>( + "select name from sqlite_master where type = 'index' and name = ? limit 1", + [indexName] + ); + expect(hit?.name).toBe(indexName); + } + + db.close(); + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 7f86e7693..661155a4f 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -762,6 +762,170 @@ function migrate(db: Database) { `); db.run("create index if not exists idx_pr_group_members_group on pr_group_members(group_id)"); db.run("create index if not exists idx_pr_group_members_pr on pr_group_members(pr_id)"); + + // Phase 1 missions model foundation. + db.run(` + create table if not exists missions ( + id text primary key, + project_id text not null, + lane_id text, + title text not null, + prompt text not null, + status text not null, + priority text not null default 'normal', + execution_mode text not null default 'local', + target_machine_id text, + outcome_summary text, + last_error text, + metadata_json text, + created_at text not null, + updated_at text not null, + started_at text, + completed_at text, + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_missions_project_updated on missions(project_id, updated_at)", + "missions", + ["project_id", "updated_at"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_missions_project_status on missions(project_id, status)", + "missions", + ["project_id", "status"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_missions_project_lane on missions(project_id, lane_id)", + "missions", + ["project_id", "lane_id"] + ); + + db.run(` + create table if not exists mission_steps ( + id text primary key, + mission_id text not null, + project_id text not null, + step_index integer not null, + title text not null, + detail text, + kind text not null default 'manual', + lane_id text, + status text not null, + metadata_json text, + created_at text not null, + updated_at text not null, + started_at text, + completed_at text, + unique(mission_id, step_index), + foreign key(mission_id) references missions(id), + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_steps_mission_index on mission_steps(mission_id, step_index)", + "mission_steps", + ["mission_id", "step_index"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_steps_project_status on mission_steps(project_id, status)", + "mission_steps", + ["project_id", "status"] + ); + + db.run(` + create table if not exists mission_events ( + id text primary key, + mission_id text not null, + project_id text not null, + event_type text not null, + actor text not null, + summary text not null, + payload_json text, + created_at text not null, + foreign key(mission_id) references missions(id), + foreign key(project_id) references projects(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_events_mission_created on mission_events(mission_id, created_at)", + "mission_events", + ["mission_id", "created_at"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_events_project_created on mission_events(project_id, created_at)", + "mission_events", + ["project_id", "created_at"] + ); + + db.run(` + create table if not exists mission_artifacts ( + id text primary key, + mission_id text not null, + project_id text not null, + artifact_type text not null, + title text not null, + description text, + uri text, + lane_id text, + metadata_json text, + created_at text not null, + updated_at text not null, + created_by text not null, + foreign key(mission_id) references missions(id), + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_artifacts_mission_created on mission_artifacts(mission_id, created_at)", + "mission_artifacts", + ["mission_id", "created_at"] + ); + + db.run(` + create table if not exists mission_interventions ( + id text primary key, + mission_id text not null, + project_id text not null, + intervention_type text not null, + status text not null, + title text not null, + body text not null, + requested_action text, + resolution_note text, + lane_id text, + metadata_json text, + created_at text not null, + updated_at text not null, + resolved_at text, + foreign key(mission_id) references missions(id), + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_interventions_mission_status on mission_interventions(mission_id, status)", + "mission_interventions", + ["mission_id", "status"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_mission_interventions_project_status on mission_interventions(project_id, status)", + "mission_interventions", + ["project_id", "status"] + ); } export async function openKvDb(dbPath: string, logger: Logger): Promise { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 27c4bd6eb..dc279f2e1 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -76,6 +76,8 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AddMissionArtifactArgs, + AddMissionInterventionArgs, KeybindingOverride, KeybindingsSnapshot, OnboardingDetectionResult, @@ -115,6 +117,7 @@ import type { LandResult, ListOverlapsArgs, LaneSummary, + ListMissionsArgs, ImportBranchLaneArgs, MergeSimulationArgs, MergeSimulationResult, @@ -175,6 +178,16 @@ import type { TerminalSessionDetail, TerminalProfilesSnapshot, TerminalSessionSummary, + ResolveMissionInterventionArgs, + MissionArtifact, + MissionDetail, + MissionIntervention, + MissionStep, + MissionSummary, + MissionsEventPayload, + CreateMissionArgs, + UpdateMissionArgs, + UpdateMissionStepArgs, TestEvent, TestRunSummary, TestSuiteDefinition, @@ -239,6 +252,17 @@ declare global { simulate: (req: AutomationSimulateRequest) => Promise; onEvent: (cb: (ev: AutomationsEventPayload) => void) => () => void; }; + missions: { + list: (args?: ListMissionsArgs) => Promise; + get: (missionId: string) => Promise; + create: (args: CreateMissionArgs) => Promise; + update: (args: UpdateMissionArgs) => Promise; + updateStep: (args: UpdateMissionStepArgs) => Promise; + addArtifact: (args: AddMissionArtifactArgs) => Promise; + addIntervention: (args: AddMissionInterventionArgs) => Promise; + resolveIntervention: (args: ResolveMissionInterventionArgs) => Promise; + onEvent: (cb: (ev: MissionsEventPayload) => void) => () => void; + }; lanes: { list: (args?: ListLanesArgs) => Promise; create: (args: CreateLaneArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5e0d1597a..56c09dd33 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -19,6 +19,8 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AddMissionArtifactArgs, + AddMissionInterventionArgs, AutomationsEventPayload, ConflictExternalResolverRunSummary, ConflictProposal, @@ -121,6 +123,7 @@ import type { LaneSummary, ListOverlapsArgs, ListLanesArgs, + ListMissionsArgs, ImportBranchLaneArgs, ListOperationsArgs, ListSessionsArgs, @@ -194,6 +197,16 @@ import type { TerminalSessionDetail, TerminalProfilesSnapshot, TerminalSessionSummary, + ResolveMissionInterventionArgs, + MissionArtifact, + MissionDetail, + MissionIntervention, + MissionStep, + MissionSummary, + MissionsEventPayload, + CreateMissionArgs, + UpdateMissionArgs, + UpdateMissionStepArgs, TestEvent, TestRunSummary, TestSuiteDefinition, @@ -273,6 +286,23 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.automationsEvent, listener); } }, + missions: { + list: async (args: ListMissionsArgs = {}): Promise => ipcRenderer.invoke(IPC.missionsList, args), + get: async (missionId: string): Promise => ipcRenderer.invoke(IPC.missionsGet, { missionId }), + create: async (args: CreateMissionArgs): Promise => ipcRenderer.invoke(IPC.missionsCreate, args), + update: async (args: UpdateMissionArgs): Promise => ipcRenderer.invoke(IPC.missionsUpdate, args), + updateStep: async (args: UpdateMissionStepArgs): Promise => ipcRenderer.invoke(IPC.missionsUpdateStep, args), + addArtifact: async (args: AddMissionArtifactArgs): Promise => ipcRenderer.invoke(IPC.missionsAddArtifact, args), + addIntervention: async (args: AddMissionInterventionArgs): Promise => + ipcRenderer.invoke(IPC.missionsAddIntervention, args), + resolveIntervention: async (args: ResolveMissionInterventionArgs): Promise => + ipcRenderer.invoke(IPC.missionsResolveIntervention, args), + onEvent: (cb: (ev: MissionsEventPayload) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: MissionsEventPayload) => cb(payload); + ipcRenderer.on(IPC.missionsEvent, listener); + return () => ipcRenderer.removeListener(IPC.missionsEvent, listener); + } + }, lanes: { list: async (args: ListLanesArgs = {}): Promise => ipcRenderer.invoke(IPC.lanesList, args), create: async (args: CreateLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesCreate, args), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 328e95668..92e7c9549 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -11,6 +11,7 @@ import { WorkspaceGraphPage } from "../graph/WorkspaceGraphPage"; import { PRsPage } from "../prs/PRsPage"; import { HistoryPage } from "../history/HistoryPage"; import { AutomationsPage } from "../automations/AutomationsPage"; +import { MissionsPage } from "../missions/MissionsPage"; import { SettingsPage } from "./SettingsPage"; import { StartupAuthPage } from "./StartupAuthPage"; import { OnboardingPage } from "../onboarding/OnboardingPage"; @@ -52,6 +53,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 96817f298..fa21def91 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -31,6 +31,7 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh { id: "go-conflicts", title: "Go to Conflicts", shortcut: "G C", run: () => navigate("/conflicts") }, { id: "go-prs", title: "Go to PRs", shortcut: "G R", run: () => navigate("/prs") }, { id: "go-history", title: "Go to History", shortcut: "G H", run: () => navigate("/history") }, + { id: "go-missions", title: "Go to Missions", shortcut: "G M", run: () => navigate("/missions") }, { id: "go-settings", title: "Go to Settings", shortcut: "G S", run: () => navigate("/settings") }, { id: "lane-next", diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index deb123553..56e3ab1f5 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { NavLink } from "react-router-dom"; -import { BookOpenText, Bug, FileCode2, GitPullRequest, History, LayoutGrid, Network, Play, Settings, TerminalSquare, Wand2 } from "lucide-react"; +import { BookOpenText, Bug, FileCode2, GitPullRequest, History, LayoutGrid, Network, Play, Rocket, Settings, TerminalSquare, Wand2 } from "lucide-react"; import { cn } from "../ui/cn"; import { useAppStore } from "../../state/appStore"; import { revealLabel } from "../../lib/platform"; @@ -16,6 +16,7 @@ const items = [ { to: "/prs", label: "PRs", icon: GitPullRequest }, { to: "/history", label: "History", icon: History }, { to: "/automations", label: "Automations", icon: Wand2 }, + { to: "/missions", label: "Missions", icon: Rocket }, { to: "/settings", label: "Settings", icon: Settings } ] as const; diff --git a/apps/desktop/src/renderer/components/missions/MissionsPage.tsx b/apps/desktop/src/renderer/components/missions/MissionsPage.tsx new file mode 100644 index 000000000..e5ea91718 --- /dev/null +++ b/apps/desktop/src/renderer/components/missions/MissionsPage.tsx @@ -0,0 +1,1065 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { + AlertTriangle, + CheckCircle2, + Clock3, + GitPullRequest, + Link2, + Loader2, + Plus, + RefreshCw, + Rocket, + Route, + TriangleAlert, + Waypoints +} from "lucide-react"; +import type { + MissionArtifactType, + MissionDetail, + MissionIntervention, + MissionPriority, + MissionStatus, + MissionStep, + MissionStepStatus, + MissionSummary +} from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { EmptyState } from "../ui/EmptyState"; +import { cn } from "../ui/cn"; + +type CreateDraft = { + title: string; + prompt: string; + laneId: string; + priority: MissionPriority; + executionMode: "local" | "relay"; + targetMachineId: string; +}; + +type ArtifactDraft = { + artifactType: MissionArtifactType; + title: string; + uri: string; + description: string; +}; + +type InterventionDraft = { + interventionType: MissionIntervention["interventionType"]; + title: string; + body: string; +}; + +const STATUS_COLUMNS: Array<{ status: MissionStatus; label: string; hint: string }> = [ + { status: "queued", label: "Queued", hint: "Ready to launch" }, + { status: "in_progress", label: "Running", hint: "Actively executing" }, + { status: "intervention_required", label: "Needs Input", hint: "Awaiting decision" }, + { status: "completed", label: "Completed", hint: "Finished with outcomes" }, + { status: "failed", label: "Failed", hint: "Needs recovery" }, + { status: "canceled", label: "Canceled", hint: "Stopped intentionally" } +]; + +function statusTone(status: MissionStatus): string { + if (status === "queued") return "text-sky-300 border-sky-500/40 bg-sky-500/10"; + if (status === "in_progress") return "text-violet-300 border-violet-500/40 bg-violet-500/10"; + if (status === "intervention_required") return "text-amber-300 border-amber-500/40 bg-amber-500/10"; + if (status === "completed") return "text-emerald-300 border-emerald-500/40 bg-emerald-500/10"; + if (status === "failed") return "text-red-300 border-red-500/40 bg-red-500/10"; + return "text-muted-fg border-border bg-card/30"; +} + +function priorityTone(priority: MissionPriority): string { + if (priority === "urgent") return "text-red-300 border-red-500/40 bg-red-500/10"; + if (priority === "high") return "text-amber-300 border-amber-500/40 bg-amber-500/10"; + if (priority === "normal") return "text-sky-300 border-sky-500/40 bg-sky-500/10"; + return "text-muted-fg border-border bg-card/30"; +} + +function stepTone(status: MissionStepStatus): string { + if (status === "succeeded") return "text-emerald-300 border-emerald-500/40 bg-emerald-500/10"; + if (status === "failed") return "text-red-300 border-red-500/40 bg-red-500/10"; + if (status === "running") return "text-violet-300 border-violet-500/40 bg-violet-500/10"; + if (status === "blocked") return "text-amber-300 border-amber-500/40 bg-amber-500/10"; + if (status === "skipped" || status === "canceled") return "text-muted-fg border-border bg-card/30"; + return "text-sky-300 border-sky-500/40 bg-sky-500/10"; +} + +function interventionTone(status: MissionIntervention["status"]): string { + if (status === "open") return "text-amber-300 border-amber-500/40 bg-amber-500/10"; + if (status === "resolved") return "text-emerald-300 border-emerald-500/40 bg-emerald-500/10"; + return "text-muted-fg border-border bg-card/30"; +} + +function formatWhen(iso: string | null): string { + if (!iso) return "-"; + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return iso; + return new Date(ts).toLocaleString(); +} + +function relativeWhen(iso: string): string { + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return iso; + const delta = Math.max(0, Date.now() - ts); + const mins = Math.floor(delta / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function countByStatus(missions: MissionSummary[]) { + const map: Record = { + queued: 0, + in_progress: 0, + intervention_required: 0, + completed: 0, + failed: 0, + canceled: 0 + }; + for (const mission of missions) { + map[mission.status] = (map[mission.status] ?? 0) + 1; + } + return map; +} + +function stepActions(step: MissionStep): Array<{ label: string; status: MissionStepStatus; variant: "primary" | "outline" | "ghost" }> { + if (step.status === "pending") { + return [{ label: "Start", status: "running", variant: "primary" }]; + } + if (step.status === "running") { + return [ + { label: "Done", status: "succeeded", variant: "primary" }, + { label: "Block", status: "blocked", variant: "outline" }, + { label: "Fail", status: "failed", variant: "ghost" } + ]; + } + if (step.status === "blocked") { + return [ + { label: "Resume", status: "running", variant: "primary" }, + { label: "Fail", status: "failed", variant: "ghost" } + ]; + } + if (step.status === "failed") { + return [{ label: "Retry", status: "running", variant: "primary" }]; + } + return []; +} + +function statusActions(status: MissionStatus): Array<{ label: string; status: MissionStatus; variant: "primary" | "outline" | "ghost" }> { + if (status === "queued") { + return [ + { label: "Start", status: "in_progress", variant: "primary" }, + { label: "Cancel", status: "canceled", variant: "outline" } + ]; + } + if (status === "in_progress") { + return [ + { label: "Mark Complete", status: "completed", variant: "primary" }, + { label: "Fail", status: "failed", variant: "ghost" } + ]; + } + if (status === "intervention_required") { + return [ + { label: "Resume", status: "in_progress", variant: "primary" }, + { label: "Fail", status: "failed", variant: "outline" }, + { label: "Cancel", status: "canceled", variant: "ghost" } + ]; + } + if (status === "failed" || status === "canceled" || status === "completed") { + return [{ label: "Requeue", status: "queued", variant: "outline" }]; + } + return []; +} + +export function MissionsPage() { + const navigate = useNavigate(); + const lanes = useAppStore((s) => s.lanes); + const refreshLanes = useAppStore((s) => s.refreshLanes); + + const [missions, setMissions] = React.useState([]); + const [selectedMissionId, setSelectedMissionId] = React.useState(null); + const [selectedMission, setSelectedMission] = React.useState(null); + + const [loading, setLoading] = React.useState(true); + const [refreshing, setRefreshing] = React.useState(false); + const [detailBusy, setDetailBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const [createBusy, setCreateBusy] = React.useState(false); + const [missionActionBusy, setMissionActionBusy] = React.useState(false); + const [stepBusyId, setStepBusyId] = React.useState(null); + const [artifactBusy, setArtifactBusy] = React.useState(false); + const [interventionBusy, setInterventionBusy] = React.useState(false); + const [outcomeBusy, setOutcomeBusy] = React.useState(false); + + const [createDraft, setCreateDraft] = React.useState({ + title: "", + prompt: "", + laneId: "", + priority: "normal", + executionMode: "local", + targetMachineId: "" + }); + const [artifactDraft, setArtifactDraft] = React.useState({ + artifactType: "pr", + title: "", + uri: "", + description: "" + }); + const [interventionDraft, setInterventionDraft] = React.useState({ + interventionType: "manual_input", + title: "", + body: "" + }); + const [outcomeDraft, setOutcomeDraft] = React.useState(""); + + const selectedMissionSummary = React.useMemo( + () => (selectedMissionId ? missions.find((mission) => mission.id === selectedMissionId) ?? null : null), + [missions, selectedMissionId] + ); + + const statusCount = React.useMemo(() => countByStatus(missions), [missions]); + + const refreshMissionList = React.useCallback( + async (opts: { preserveSelection?: boolean; silent?: boolean } = {}) => { + if (!opts.silent) { + setRefreshing(true); + } + + try { + if (!lanes.length) { + await refreshLanes().catch(() => {}); + } + const list = await window.ade.missions.list({ limit: 300 }); + setMissions(list); + setError(null); + + const preserve = opts.preserveSelection ?? true; + if (!preserve) { + setSelectedMissionId(list[0]?.id ?? null); + return; + } + + setSelectedMissionId((prev) => { + if (prev && list.some((mission) => mission.id === prev)) return prev; + return list[0]?.id ?? null; + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [lanes.length, refreshLanes] + ); + + const loadMissionDetail = React.useCallback(async (missionId: string) => { + const trimmed = missionId.trim(); + if (!trimmed.length) return; + setDetailBusy(true); + try { + const detail = await window.ade.missions.get(trimmed); + setSelectedMission(detail); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setDetailBusy(false); + } + }, []); + + React.useEffect(() => { + void refreshMissionList({ preserveSelection: true }); + }, [refreshMissionList]); + + React.useEffect(() => { + if (!selectedMissionId) { + setSelectedMission(null); + return; + } + void loadMissionDetail(selectedMissionId); + }, [selectedMissionId, loadMissionDetail]); + + React.useEffect(() => { + const unsub = window.ade.missions.onEvent((payload) => { + void refreshMissionList({ preserveSelection: true, silent: true }); + if (payload.missionId && payload.missionId === selectedMissionId) { + void loadMissionDetail(payload.missionId); + } + }); + return () => unsub(); + }, [loadMissionDetail, refreshMissionList, selectedMissionId]); + + React.useEffect(() => { + setOutcomeDraft(selectedMission?.outcomeSummary ?? ""); + }, [selectedMission?.id, selectedMission?.outcomeSummary]); + + const launchMission = async () => { + const prompt = createDraft.prompt.trim(); + if (!prompt.length) { + setError("Mission prompt is required."); + return; + } + + setCreateBusy(true); + try { + const created = await window.ade.missions.create({ + title: createDraft.title.trim() || undefined, + prompt, + laneId: createDraft.laneId || undefined, + priority: createDraft.priority, + executionMode: createDraft.executionMode, + targetMachineId: createDraft.targetMachineId.trim() || undefined + }); + + setCreateDraft((prev) => ({ ...prev, title: "", prompt: "" })); + setSelectedMissionId(created.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + await loadMissionDetail(created.id); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreateBusy(false); + } + }; + + const updateMissionStatus = async (status: MissionStatus) => { + if (!selectedMission) return; + setMissionActionBusy(true); + try { + const updated = await window.ade.missions.update({ + missionId: selectedMission.id, + status, + ...(status === "completed" ? { outcomeSummary: outcomeDraft.trim() || null } : {}) + }); + setSelectedMission(updated); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setMissionActionBusy(false); + } + }; + + const saveOutcome = async () => { + if (!selectedMission) return; + setOutcomeBusy(true); + try { + const updated = await window.ade.missions.update({ + missionId: selectedMission.id, + outcomeSummary: outcomeDraft.trim() || null + }); + setSelectedMission(updated); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setOutcomeBusy(false); + } + }; + + const addArtifact = async () => { + if (!selectedMission) return; + if (!artifactDraft.title.trim()) { + setError("Artifact title is required."); + return; + } + + setArtifactBusy(true); + try { + await window.ade.missions.addArtifact({ + missionId: selectedMission.id, + artifactType: artifactDraft.artifactType, + title: artifactDraft.title.trim(), + uri: artifactDraft.uri.trim() || undefined, + description: artifactDraft.description.trim() || undefined, + laneId: selectedMission.laneId ?? undefined + }); + + setArtifactDraft((prev) => ({ ...prev, title: "", uri: "", description: "" })); + await loadMissionDetail(selectedMission.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setArtifactBusy(false); + } + }; + + const addIntervention = async () => { + if (!selectedMission) return; + if (!interventionDraft.title.trim() || !interventionDraft.body.trim()) { + setError("Intervention title and body are required."); + return; + } + + setInterventionBusy(true); + try { + await window.ade.missions.addIntervention({ + missionId: selectedMission.id, + interventionType: interventionDraft.interventionType, + title: interventionDraft.title.trim(), + body: interventionDraft.body.trim(), + laneId: selectedMission.laneId ?? undefined + }); + + setInterventionDraft((prev) => ({ ...prev, title: "", body: "" })); + await loadMissionDetail(selectedMission.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setInterventionBusy(false); + } + }; + + const resolveIntervention = async (interventionId: string, status: "resolved" | "dismissed") => { + if (!selectedMission) return; + setInterventionBusy(true); + try { + await window.ade.missions.resolveIntervention({ + missionId: selectedMission.id, + interventionId, + status + }); + await loadMissionDetail(selectedMission.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setInterventionBusy(false); + } + }; + + const updateStep = async (step: MissionStep, status: MissionStepStatus) => { + if (!selectedMission) return; + setStepBusyId(step.id); + try { + await window.ade.missions.updateStep({ + missionId: selectedMission.id, + stepId: step.id, + status, + ...(status === "failed" ? { note: "Marked failed from Missions tab." } : {}) + }); + await loadMissionDetail(selectedMission.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setStepBusyId(null); + } + }; + + const addQuickIntervention = async () => { + if (!selectedMission) return; + setInterventionBusy(true); + try { + await window.ade.missions.addIntervention({ + missionId: selectedMission.id, + interventionType: "manual_input", + title: "Operator input requested", + body: "Mission requests human input before proceeding.", + laneId: selectedMission.laneId ?? undefined + }); + await loadMissionDetail(selectedMission.id); + await refreshMissionList({ preserveSelection: true, silent: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setInterventionBusy(false); + } + }; + + const openArtifact = async (uri: string | null) => { + const target = (uri ?? "").trim(); + if (!target) return; + try { + await window.ade.app.openExternal(target); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const jumpToLane = (laneId: string | null) => { + if (!laneId) return; + navigate(`/lanes?laneId=${encodeURIComponent(laneId)}`); + }; + + return ( +
+
+
+
+
+ +
+
+
+ + Missions +
+
+ Launch plain-English tasks, track execution across lanes, and capture outcomes for PR handoff. +
+
+
+ + +
+
+ +
+ {STATUS_COLUMNS.map((column) => ( +
+
{column.label}
+
{statusCount[column.status]}
+
{column.hint}
+
+ ))} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ + Launch Mission +
+
+ + + + + + + +
+ +
+