From 5b6bc636dafb52ac28f5c18e9323b7f4bdeca596 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 19 May 2026 16:46:56 +0000 Subject: [PATCH] fix: align desktop task contract and protocol sync - align desktop task APIs with workspace-scoped Tauri commands - move shared protocol types into domain files and reuse them in desktop - add protocol drift checks plus CI coverage for shared Rust/TS models --- .github/workflows/ci.yml | 47 ++ CONTRIBUTING.md | 4 +- apps/desktop/src-tauri/src/lib.rs | 3 +- apps/desktop/src/App.tsx | 222 +++++++--- .../desktop/src/components/TaskThreadView.tsx | 416 +++++++++++++----- apps/desktop/src/components/ThreadSidebar.tsx | 137 +++--- apps/desktop/src/lib/codraTaskApi.ts | 240 +++++----- apps/desktop/src/views/TaskLoopView.tsx | 95 +++- docs/TASK_LOOP_PROTOCOL_SYNC.md | 129 ++++++ package.json | 3 +- packages/shared/core.ts | 87 ++++ packages/shared/executor.ts | 78 ++++ packages/shared/index.ts | 288 +----------- packages/shared/integrations.ts | 127 ++++++ packages/shared/planner.ts | 91 ++++ packages/shared/provider.ts | 141 ++++++ packages/shared/runtime.ts | 80 ++++ packages/shared/task-loop.ts | 107 +++++ packages/shared/verifier.ts | 72 +++ scripts/check-task-loop-protocol-sync.mjs | 311 +++++++++++++ 20 files changed, 2006 insertions(+), 672 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/TASK_LOOP_PROTOCOL_SYNC.md create mode 100644 packages/shared/core.ts create mode 100644 packages/shared/executor.ts create mode 100644 packages/shared/integrations.ts create mode 100644 packages/shared/planner.ts create mode 100644 packages/shared/provider.ts create mode 100644 packages/shared/runtime.ts create mode 100644 packages/shared/task-loop.ts create mode 100644 packages/shared/verifier.ts create mode 100644 scripts/check-task-loop-protocol-sync.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9234ab1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + desktop-contract-checks: + name: Desktop contract checks + runs-on: ubuntu-22.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install workspace dependencies + run: pnpm install --no-frozen-lockfile + + - name: Check task-loop protocol sync + run: pnpm check:task-loop-protocol-sync + + - name: Build desktop app + run: pnpm --filter desktop build + + - name: Check desktop Tauri crate + run: cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce9751f..f774aed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,9 @@ Thank you for your interest in contributing to Codra by Talocode. 6. **Run checks** before submitting: ```bash pnpm install - cargo check + pnpm check:task-loop-protocol-sync + pnpm --filter desktop build + cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml ``` ## Code of Conduct diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6a9794a..2eb6a6c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -869,8 +869,7 @@ fn codra_run_verification( ) -> Result { let task_store = TaskStore::new(&workspace_path); let verifier = TaskVerifier::::new(task_store.clone(), RealCommandRunner); - // Simplified: just return the task after attempting verification - let _ = verifier.run_verification(&task_id, None); + verifier.run_verification(&task_id, None)?; task_store.load_task(&task_id) } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9fff036..47ad033 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,40 +1,59 @@ -import { useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, FolderOpen, Play } from 'lucide-react'; -import { createTask, getTaskEvents, listTasks, scanWorkspace } from './lib/codraTaskApi'; -import type { Task, TaskEvent, WorkspaceContext } from './lib/codraTaskApi'; -import { ThreadSidebar } from './components/ThreadSidebar'; -import { TaskThreadView } from './components/TaskThreadView'; -import { ModelPicker } from './components/ModelPicker'; -import { getModelLabel, loadModelConfig, type ModelConfig } from './lib/modelConfig'; -import { selectWorkspaceFolder } from './lib/workspacePicker'; -import { isTauriRuntime } from './lib/tauriRuntime'; - -const LAST_WORKSPACE_KEY = 'codra_last_workspace'; +import { useEffect, useMemo, useState } from "react"; +import { AlertTriangle, FolderOpen, Play } from "lucide-react"; +import { + createTask, + getTaskEvents, + listTasks, + scanWorkspace, +} from "./lib/codraTaskApi"; +import type { Task, TaskEvent, WorkspaceContext } from "./lib/codraTaskApi"; +import { ThreadSidebar } from "./components/ThreadSidebar"; +import { TaskThreadView } from "./components/TaskThreadView"; +import { ModelPicker } from "./components/ModelPicker"; +import { + getModelLabel, + loadModelConfig, + type ModelConfig, +} from "./lib/modelConfig"; +import { selectWorkspaceFolder } from "./lib/workspacePicker"; +import { isTauriRuntime } from "./lib/tauriRuntime"; + +const LAST_WORKSPACE_KEY = "codra_last_workspace"; function sortTasks(tasks: Task[]) { return [...tasks].sort((a, b) => { - const aTime = new Date(a.updated_at).getTime(); - const bTime = new Date(b.updated_at).getTime(); + const aTime = parseTaskTimestamp(a.updated_at); + const bTime = parseTaskTimestamp(b.updated_at); return bTime - aTime; }); } +function parseTaskTimestamp(input: string) { + const unixSeconds = Number(input); + if (Number.isFinite(unixSeconds) && unixSeconds > 0) { + return unixSeconds * 1000; + } + + const parsed = Date.parse(input); + return Number.isFinite(parsed) ? parsed : 0; +} + function upsertTask(tasks: Task[], next: Task) { return sortTasks([next, ...tasks.filter((task) => task.id !== next.id)]); } function basename(path: string) { - const normalized = path.replace(/[\\/]+$/, ''); - if (!normalized) return ''; + const normalized = path.replace(/[\\/]+$/, ""); + if (!normalized) return ""; const parts = normalized.split(/[\\/]/).filter(Boolean); return parts.at(-1) || normalized; } function formatStatusLabel(status?: string | null) { - if (!status) return 'Local-first mode'; + if (!status) return "Local-first mode"; return status - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/_/g, ' ') + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") .replace(/^./, (char) => char.toUpperCase()); } @@ -42,10 +61,13 @@ export default function App() { const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); const [events, setEvents] = useState([]); - const [workspacePath, setWorkspacePath] = useState(''); - const [workspaceContext, setWorkspaceContext] = useState(null); - const [modelConfig, setModelConfig] = useState(() => loadModelConfig()); - const [prompt, setPrompt] = useState(''); + const [workspacePath, setWorkspacePath] = useState(""); + const [workspaceContext, setWorkspaceContext] = + useState(null); + const [modelConfig, setModelConfig] = useState(() => + loadModelConfig(), + ); + const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); const [isCreatingTask, setIsCreatingTask] = useState(false); const [isScanningWorkspace, setIsScanningWorkspace] = useState(false); @@ -57,25 +79,50 @@ export default function App() { return tasks.find((task) => task.id === selectedTaskId) || null; }, [tasks, selectedTaskId]); - const workspaceLabel = basename(workspacePath) || 'Select workspace'; - const modelLabel = getModelLabel(modelConfig.selectedProvider, modelConfig.selectedModel); + const workspaceLabel = basename(workspacePath) || "Select workspace"; + const modelLabel = getModelLabel( + modelConfig.selectedProvider, + modelConfig.selectedModel, + ); + + async function loadTasksForWorkspace(path: string) { + const nextPath = path.trim(); + if (!nextPath) { + setTasks([]); + setSelectedTaskId(null); + setEvents([]); + return; + } + + const nextTasks = sortTasks(await listTasks(nextPath)); + setTasks(nextTasks); + setSelectedTaskId((current) => + current && nextTasks.some((task) => task.id === current) + ? current + : (nextTasks[0]?.id ?? null), + ); + } useEffect(() => { let cancelled = false; async function bootstrap() { + const lastWorkspace = localStorage.getItem(LAST_WORKSPACE_KEY); + + if (lastWorkspace && !cancelled) { + setWorkspacePath(lastWorkspace); + } + try { - const list = await listTasks(); + const list = lastWorkspace ? await listTasks(lastWorkspace) : []; if (!cancelled) { setTasks(sortTasks(list)); } } catch (cause) { - console.error('[Codra] Failed to load tasks:', cause); + console.error("[Codra] Failed to load tasks:", cause); } - const lastWorkspace = localStorage.getItem(LAST_WORKSPACE_KEY); if (lastWorkspace && !cancelled) { - setWorkspacePath(lastWorkspace); if (isTauri) { void scanWorkspaceAtPath(lastWorkspace); } @@ -100,13 +147,21 @@ export default function App() { let cancelled = false; async function loadEvents() { + const currentTask = tasks.find((task) => task.id === taskId); + if (!currentTask) { + if (!cancelled) { + setEvents([]); + } + return; + } + try { - const next = await getTaskEvents(taskId); + const next = await getTaskEvents(taskId, currentTask.workspace_path); if (!cancelled) { setEvents(next); } } catch (cause) { - console.error('[Codra] Failed to load task events:', cause); + console.error("[Codra] Failed to load task events:", cause); if (!cancelled) { setEvents([]); } @@ -118,7 +173,7 @@ export default function App() { return () => { cancelled = true; }; - }, [selectedTaskId]); + }, [selectedTaskId, tasks]); async function scanWorkspaceAtPath(path: string) { const nextPath = path.trim(); @@ -129,6 +184,7 @@ export default function App() { setIsScanningWorkspace(true); try { + await loadTasksForWorkspace(nextPath); const ctx = await scanWorkspace(nextPath); setWorkspaceContext(ctx); setError(null); @@ -143,18 +199,28 @@ export default function App() { function handleNewThread() { setSelectedTaskId(null); setEvents([]); - setPrompt(''); + setPrompt(""); setError(null); } function handleSelectTask(task: Task) { + if (task.workspace_path !== workspacePath) { + setWorkspacePath(task.workspace_path); + localStorage.setItem(LAST_WORKSPACE_KEY, task.workspace_path); + if (isTauri) { + void scanWorkspaceAtPath(task.workspace_path); + } + } + setSelectedTaskId(task.id); setError(null); } async function handleSelectWorkspace() { if (!isTauri) { - setError('Open Codra in the desktop app window to use the native folder picker.'); + setError( + "Open Codra in the desktop app window to use the native folder picker.", + ); return; } @@ -175,17 +241,19 @@ export default function App() { const trimmedPrompt = prompt.trim(); if (!trimmedWorkspace) { - setError('Workspace path is required.'); + setError("Workspace path is required."); return; } if (!trimmedPrompt) { - setError('Prompt is required.'); + setError("Prompt is required."); return; } if (!isTauri) { - setError('Tauri runtime unavailable. Open Codra in the desktop app window.'); + setError( + "Tauri runtime unavailable. Open Codra in the desktop app window.", + ); return; } @@ -201,10 +269,13 @@ export default function App() { setTasks((current) => upsertTask(current, created)); setSelectedTaskId(created.id); - setPrompt(''); + setPrompt(""); try { - const nextEvents = await getTaskEvents(created.id); + const nextEvents = await getTaskEvents( + created.id, + created.workspace_path, + ); setEvents(nextEvents); } catch { setEvents([]); @@ -228,14 +299,24 @@ export default function App() { } try { - const next = await getTaskEvents(selectedTaskId); + if (!selectedTask) { + setEvents([]); + return; + } + + const next = await getTaskEvents( + selectedTaskId, + selectedTask.workspace_path, + ); setEvents(next); } catch { setEvents([]); } } - const canCreateTask = Boolean(isTauri && workspacePath.trim() && prompt.trim() && !isCreatingTask); + const canCreateTask = Boolean( + isTauri && workspacePath.trim() && prompt.trim() && !isCreatingTask, + ); return (
@@ -256,10 +337,14 @@ export default function App() {
-
Codra interface artifact
+
+ Codra interface artifact +

- {selectedTask ? selectedTask.title || 'Task thread' : 'What should Codra do in this workspace?'} + {selectedTask + ? selectedTask.title || "Task thread" + : "What should Codra do in this workspace?"}

{selectedTask ? ( @@ -280,7 +365,9 @@ export default function App() { > Workspace - {workspaceLabel} + + {workspaceLabel} +
@@ -294,7 +381,8 @@ export default function App() {
- Tauri runtime unavailable — native folder selection and task execution are only available in the desktop app window. + Tauri runtime unavailable — native folder selection and task + execution are only available in the desktop app window.
)} @@ -304,12 +392,15 @@ export default function App() {
-
Codra interface artifact
+
+ Codra interface artifact +

What should Codra do in this workspace?

- Codra scans the workspace, drafts a plan, and waits for your approval before it touches files. + Codra scans the workspace, drafts a plan, and waits for + your approval before it touches files.

@@ -325,7 +416,14 @@ export default function App() { }} onBlur={() => { if (workspacePath.trim()) { - localStorage.setItem(LAST_WORKSPACE_KEY, workspacePath.trim()); + const nextPath = workspacePath.trim(); + localStorage.setItem( + LAST_WORKSPACE_KEY, + nextPath, + ); + if (isTauri) { + void scanWorkspaceAtPath(nextPath); + } } }} placeholder="Select or type a workspace path" @@ -337,14 +435,17 @@ export default function App() { onClick={handleSelectWorkspace} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-2xl border border-[rgba(155,192,255,0.18)] bg-[linear-gradient(180deg,rgba(77,137,255,1),rgba(50,102,222,1))] px-4 text-sm font-semibold text-white shadow-[0_10px_30px_rgba(77,137,255,0.22),0_0_0_1px_rgba(255,255,255,0.05)_inset] transition hover:translate-y-[-1px] hover:shadow-[0_14px_36px_rgba(77,137,255,0.26),0_0_0_1px_rgba(255,255,255,0.05)_inset]" > - {isTauri ? 'Browse workspace' : 'Open in Tauri'} + {isTauri ? "Browse workspace" : "Open in Tauri"}
{workspaceContext ? (
- Stack: {workspaceContext.detected_stack.slice(0, 2).join(' · ') || 'Unknown'} + Stack:{" "} + {workspaceContext.detected_stack + .slice(0, 2) + .join(" · ") || "Unknown"} {workspaceContext.git_branch && ( @@ -352,13 +453,20 @@ export default function App() { )} - {workspaceContext.is_git_repo ? 'Git repo' : 'Not a git repo'} + {workspaceContext.is_git_repo + ? "Git repo" + : "Not a git repo"} - {workspaceContext.detected_config_files.slice(0, 2).map((file) => ( - - {file} - - ))} + {workspaceContext.detected_config_files + .slice(0, 2) + .map((file) => ( + + {file} + + ))}
) : null} @@ -392,7 +500,7 @@ export default function App() { className="inline-flex min-h-11 items-center justify-center gap-2 rounded-2xl bg-white px-5 text-sm font-semibold text-black transition hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-40" > - {isCreatingTask ? 'Creating…' : 'Create Task'} + {isCreatingTask ? "Creating…" : "Create Task"}
diff --git a/apps/desktop/src/components/TaskThreadView.tsx b/apps/desktop/src/components/TaskThreadView.tsx index f5c35e9..3f4fd54 100644 --- a/apps/desktop/src/components/TaskThreadView.tsx +++ b/apps/desktop/src/components/TaskThreadView.tsx @@ -1,7 +1,12 @@ -import { useState, type ReactNode } from 'react'; -import { AlertTriangle, Check, Clock, Terminal, Play } from 'lucide-react'; -import { approveRepair, approveTask, cancelTask, executeTask } from '../lib/codraTaskApi'; -import type { Task, TaskEvent, WorkspaceContext } from '../lib/codraTaskApi'; +import { useState, type ReactNode } from "react"; +import { AlertTriangle, Check, Clock, Terminal, Play } from "lucide-react"; +import { + approveRepair, + approveTask, + cancelTask, + executeTask, +} from "../lib/codraTaskApi"; +import type { Task, TaskEvent, WorkspaceContext } from "../lib/codraTaskApi"; interface TaskThreadViewProps { task: Task | null; @@ -44,10 +49,11 @@ export function TaskThreadView({ } const status = task.status; - const workspaceLabel = workspaceContext?.workspace_path || workspacePath || task.workspace_path; + const workspaceLabel = + workspaceContext?.workspace_path || workspacePath || task.workspace_path; const currentWorkspaceName = basename(workspaceLabel); const statusLabel = formatStatusLabel(status); - const canRun = status === 'Approved'; + const canRun = status === "approved"; const hasRepairPlan = Boolean(task.repair_plan); return ( @@ -55,10 +61,12 @@ export function TaskThreadView({
-
Task thread
+
+ Task thread +

- {task.title || 'Untitled thread'} + {task.title || "Untitled thread"}

{statusLabel} @@ -89,17 +97,41 @@ export function TaskThreadView({
-

{task.user_prompt}

+

+ {task.user_prompt} +

- + {workspaceContext ? (
- - - - + + + +
{workspaceContext.git_status_summary && ( @@ -114,11 +146,16 @@ export function TaskThreadView({ Config files
- {workspaceContext.detected_config_files.slice(0, 6).map((file) => ( - - {file} - - ))} + {workspaceContext.detected_config_files + .slice(0, 6) + .map((file) => ( + + {file} + + ))}
)} @@ -129,36 +166,46 @@ export function TaskThreadView({ Suggested commands
- {workspaceContext.suggested_commands.slice(0, 3).map((command) => ( -
-
-
$ {command.command}
- - {command.risk_level} - + {workspaceContext.suggested_commands + .slice(0, 3) + .map((command) => ( +
+
+
+ $ {command.command} +
+ + {command.risk_level} + +
+
+ {command.reason} +
-
{command.reason}
-
- ))} + ))}
)}
) : (
- Workspace scan will appear here after Codra selects or scans a folder. + Workspace scan will appear here after Codra selects or scans a + folder.
)}
-
Memory layer coming next.
+
+ Memory layer coming next. +

- This card is reserved for remembered project facts, approval habits, and task-specific reminders. + This card is reserved for remembered project facts, approval + habits, and task-specific reminders.

@@ -167,24 +214,37 @@ export function TaskThreadView({ {task.plan ? (
- Risk: {task.plan.risk_level} - {task.plan.requires_approval ? 'Approval required' : 'Auto-approve'} + Risk: {task.plan.risk_level} + + + {task.plan.requires_approval + ? "Approval required" + : "Auto-approve"}
-

{task.plan.summary}

+

+ {task.plan.summary} +

{task.plan.steps.map((step, index) => ( -
+
{index + 1}
-
{step.title}
-
{step.description}
+
+ {step.title} +
+
+ {step.description} +
@@ -192,9 +252,18 @@ export function TaskThreadView({
- - - + + +
) : ( @@ -204,30 +273,43 @@ export function TaskThreadView({ )} - {(status === 'AwaitingRepairApproval' || status === 'Failed' || hasRepairPlan) && ( + {(status === "awaiting_repair_approval" || + status === "failed" || + hasRepairPlan) && (
- Repair / failure context + + Repair / failure context +

- {task.repair_plan?.summary || task.error || 'Codra will summarize the repair path here if the execution pass needs follow-up.'} + {task.repair_plan?.summary || + task.error || + "Codra will summarize the repair path here if the execution pass needs follow-up."}

{task.repair_plan?.steps?.length ? (
{task.repair_plan.steps.map((step, index) => ( -
+
{index + 1}
-
{step.title}
-
{step.description}
+
+ {step.title} +
+
+ {step.description} +
@@ -238,13 +320,23 @@ export function TaskThreadView({ )} - - {status === 'AwaitingApproval' && ( + + {status === "awaiting_approval" && (
-

Review the plan above before Codra can modify files.

+

+ Review the plan above before Codra can modify files. +

)} - {status === 'Approved' && ( + {status === "approved" && (
-

The plan is approved. Run Codra when you are ready.

+

+ The plan is approved. Run Codra when you are ready. +

)} - {(status === 'Executing' || status === 'Verifying' || status === 'Repairing') && ( + {(status === "executing" || + status === "verifying" || + status === "repairing") && (
@@ -297,12 +413,18 @@ export function TaskThreadView({
)} - {status === 'AwaitingRepairApproval' && ( + {status === "awaiting_repair_approval" && (
-

Codra generated a repair pass. Approve it to continue.

+

+ Codra generated a repair pass. Approve it to continue. +

)} - {status === 'Completed' && ( + {status === "completed" && (
- Task complete. Review the final report below for the authoritative result. + Task complete. Review the final report below for the + authoritative result.
)} - {status === 'Failed' && ( + {status === "failed" && (
- Task failed. Review the repair summary and command output above before retrying. + Task failed. Review the repair summary and command output above + before retrying.
)} @@ -337,14 +469,19 @@ export function TaskThreadView({
{task.commands_run.length > 0 ? ( task.commands_run.map((commandRun, index) => ( -
+
Command {index + 1}
-
$ {commandRun.command}
+
+ $ {commandRun.command} +
{commandRun.status} @@ -353,11 +490,15 @@ export function TaskThreadView({
- +
- {(commandRun.stdout_preview || commandRun.stderr_preview) && ( + {(commandRun.stdout_preview || + commandRun.stderr_preview) && (
{commandRun.stdout_preview && (
@@ -389,8 +530,12 @@ export function TaskThreadView({
                 
{task.verification_result && (
-
Verification
-
{task.verification_result.summary}
+
+ Verification +
+
+ {task.verification_result.summary} +
{task.verification_result.errors.length > 0 && (
    {task.verification_result.errors.map((item) => ( @@ -403,7 +548,9 @@ export function TaskThreadView({
) : task.verification_result ? (
-
{task.verification_result.summary}
+
+ {task.verification_result.summary} +
{task.verification_result.errors.length > 0 && (
    {task.verification_result.errors.map((item) => ( @@ -430,11 +577,16 @@ export function TaskThreadView({
    {events.length > 0 ? ( events.slice(0, 10).map((event) => ( -
    +
    - {new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {event.event_type} + {formatTimestamp(event.timestamp)} · {event.event_type} +
    +
    + {event.message}
    -
    {event.message}
    )) ) : ( @@ -458,7 +610,7 @@ function StreamCard({ }: { eyebrow: string; title: string; - tone: 'default' | 'blue' | 'amber' | 'rose' | 'emerald'; + tone: "default" | "blue" | "amber" | "rose" | "emerald"; children: ReactNode; }) { const styles = toneStyles[tone]; @@ -466,14 +618,23 @@ function StreamCard({ return (
    -
    {eyebrow}
    -
    {title}
    +
    + {eyebrow} +
    +
    + {title} +
    {children} @@ -484,64 +645,91 @@ function StreamCard({ function InfoRow({ label, value }: { label: string; value: string }) { return (
    -
    {label}
    +
    + {label} +
    {value}
    ); } -function approvalTone(status: Task['status']): 'default' | 'blue' | 'amber' | 'rose' | 'emerald' { +function approvalTone( + status: Task["status"], +): "default" | "blue" | "amber" | "rose" | "emerald" { switch (status) { - case 'AwaitingApproval': - case 'Approved': - return 'blue'; - case 'AwaitingRepairApproval': - case 'Failed': - return 'rose'; - case 'Executing': - case 'Verifying': - case 'Repairing': - case 'RepairPlanning': - return 'amber'; - case 'Completed': - return 'emerald'; + case "awaiting_approval": + case "approved": + return "blue"; + case "awaiting_repair_approval": + case "failed": + return "rose"; + case "executing": + case "verifying": + case "repairing": + case "repair_planning": + return "amber"; + case "completed": + return "emerald"; default: - return 'default'; + return "default"; } } -function formatStatusLabel(status: Task['status']) { +function formatStatusLabel(status: Task["status"]) { return status - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/_/g, ' ') + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") .replace(/^./, (char) => char.toUpperCase()); } function basename(path: string) { - const normalized = path.replace(/[\\/]+$/, ''); + const normalized = path.replace(/[\\/]+$/, ""); const parts = normalized.split(/[\\/]/).filter(Boolean); - return parts.at(-1) || normalized || 'Workspace'; + return parts.at(-1) || normalized || "Workspace"; +} + +function parseTaskTimestamp(input: string) { + const unixSeconds = Number(input); + if (Number.isFinite(unixSeconds) && unixSeconds > 0) { + return unixSeconds * 1000; + } + + const parsed = Date.parse(input); + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatTimestamp(input: string) { + try { + return new Date(parseTaskTimestamp(input)).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "numeric", + }); + } catch { + return input; + } } const toneStyles = { default: { - wrapper: 'border-white/[0.06] bg-[#0a0f18]', - eyebrow: 'text-[#6f7889]', + wrapper: "border-white/[0.06] bg-[#0a0f18]", + eyebrow: "text-[#6f7889]", }, blue: { - wrapper: 'border-[rgba(155,192,255,0.16)] bg-[rgba(77,137,255,0.08)]', - eyebrow: 'text-[#9bc0ff]', + wrapper: "border-[rgba(155,192,255,0.16)] bg-[rgba(77,137,255,0.08)]", + eyebrow: "text-[#9bc0ff]", }, amber: { - wrapper: 'border-[rgba(240,179,95,0.16)] bg-[rgba(240,179,95,0.08)]', - eyebrow: 'text-[#f0b35f]', + wrapper: "border-[rgba(240,179,95,0.16)] bg-[rgba(240,179,95,0.08)]", + eyebrow: "text-[#f0b35f]", }, rose: { - wrapper: 'border-[rgba(240,125,151,0.18)] bg-[rgba(240,125,151,0.08)]', - eyebrow: 'text-[#f07d97]', + wrapper: "border-[rgba(240,125,151,0.18)] bg-[rgba(240,125,151,0.08)]", + eyebrow: "text-[#f07d97]", }, emerald: { - wrapper: 'border-[rgba(77,137,255,0.16)] bg-[rgba(77,137,255,0.08)]', - eyebrow: 'text-[#9bc0ff]', + wrapper: "border-[rgba(77,137,255,0.16)] bg-[rgba(77,137,255,0.08)]", + eyebrow: "text-[#9bc0ff]", }, } as const; diff --git a/apps/desktop/src/components/ThreadSidebar.tsx b/apps/desktop/src/components/ThreadSidebar.tsx index 8eaeafe..ecf8168 100644 --- a/apps/desktop/src/components/ThreadSidebar.tsx +++ b/apps/desktop/src/components/ThreadSidebar.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; -import { FolderOpen, Plus, Search, Settings } from 'lucide-react'; -import type { Task, WorkspaceContext } from '../lib/codraTaskApi'; +import { useMemo, useState } from "react"; +import { FolderOpen, Plus, Search, Settings } from "lucide-react"; +import type { Task, WorkspaceContext } from "../lib/codraTaskApi"; interface ThreadSidebarProps { tasks: Task[]; @@ -23,7 +23,7 @@ export function ThreadSidebar({ workspaceContext, className, }: ThreadSidebarProps) { - const [search, setSearch] = useState(''); + const [search, setSearch] = useState(""); const filteredTasks = useMemo(() => { const query = search.trim().toLowerCase(); @@ -43,11 +43,11 @@ export function ThreadSidebar({ return (
    @@ -164,7 +189,7 @@ export function TaskLoopView() {
    selectTask(t)} - className={`cursor-pointer rounded p-3 mb-1 text-sm ${selectedTask?.id === t.id ? 'bg-violet-600/20' : 'bg-white/[0.03]'}`} + className={`cursor-pointer rounded p-3 mb-1 text-sm ${selectedTask?.id === t.id ? "bg-violet-600/20" : "bg-white/[0.03]"}`} > {t.title || t.user_prompt.slice(0, 60)} — {t.status}
    @@ -173,19 +198,41 @@ export function TaskLoopView() { {selectedTask && (
    -
    Selected: {selectedTask.title}
    +
    + Selected: {selectedTask.title} +
    - {selectedTask.status === 'AwaitingApproval' && ( + {selectedTask.status === "awaiting_approval" && ( <> - - + + )} - {selectedTask.status === 'Approved' && ( - + {selectedTask.status === "approved" && ( + )} - {selectedTask.status === 'AwaitingRepairApproval' && ( - + {selectedTask.status === "awaiting_repair_approval" && ( + )}
    diff --git a/docs/TASK_LOOP_PROTOCOL_SYNC.md b/docs/TASK_LOOP_PROTOCOL_SYNC.md new file mode 100644 index 0000000..a9455a0 --- /dev/null +++ b/docs/TASK_LOOP_PROTOCOL_SYNC.md @@ -0,0 +1,129 @@ +# Shared protocol sync guardrail + +Codra currently mirrors several Rust protocol domains into TypeScript files under `packages/shared/`. + +Rust source of truth: + +- `crates/codra-protocol/src/lib.rs` + +Current TypeScript mirrors covered by the guardrail: + +- `packages/shared/task-loop.ts` +- `packages/shared/planner.ts` +- `packages/shared/executor.ts` +- `packages/shared/verifier.ts` + +That mirror is still manual, so this repo includes a lightweight guardrail to catch drift before it ships. + +## What the guardrail checks + +Run: + +```bash +pnpm check:task-loop-protocol-sync +``` + +The script compares selected Rust protocol models against the shared TypeScript mirror files and fails when it detects drift in: + +### Task-loop domain + +- `TaskStatus` +- `Task` +- `TaskPlan` +- `TaskStep` +- `FileChange` +- `CommandRun` +- `VerificationResult` +- `TaskEvent` +- `WorkspaceFileNode` +- `DetectedCommand` +- `WorkspaceContext` +- `FileNodeKind` vs `WorkspaceFileNode.kind` + +### Planner domain + +- unions: + - `PlanStatus` + - `PlanningMode` + - `PlanStepStatus` + - `PlanStepKind` +- interfaces: + - `TaskRequest` + - `TaskContext` + - `RiskItem` + - `AssumptionItem` + - `PlanDependency` + - `PlanStep` + - `ArchitectureProposal` + - `ExecutionPlan` + - `PlannerOutput` + - `PlannerDecision` + +### Executor domain + +- unions: + - `ExecutionStatus` + - `ExecutionMode` + - `StepExecutionStatus` + - `ActionKind` + - `PatchProposalStatus` +- interfaces: + - `ExecutionState` + - `ObservationRecord` + - `PatchProposal` + - `StepExecutionRecord` + - `ActionIntent` + +### Verifier domain + +- unions: + - `VerificationStatus` + - `VerificationCheckKind` + - `VerificationSeverity` + - `FailureClassification` +- interfaces: + - `VerificationCheck` + - `VerificationFinding` + - `RetryRecommendation` + - `RetryRequest` + - `VerificationState` + +## What it does not check yet + +This is intentionally lightweight. It does **not** yet verify: + +- exact scalar type compatibility beyond field presence and enum/union variants +- optional-vs-required parity for every field +- integration, provider, runtime, or core shared domains +- generated TS from Rust schemas + +## Expected workflow + +Whenever you change one of the guarded Rust protocol domains: + +1. Update `crates/codra-protocol/src/lib.rs` +2. Mirror the change in the matching file under `packages/shared/` +3. Run: + ```bash + pnpm check:task-loop-protocol-sync + pnpm --filter desktop build + cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml + ``` + +Whenever you change the TS mirror first: + +1. Update the matching `packages/shared/*.ts` file +2. Confirm the Rust protocol already matches or update it +3. Run the same commands above + +## Why this scope + +The biggest current drift risk is still the desktop/Tauri/shared-protocol boundary. This guardrail now covers the highest-value domains in that path without adding a codegen pipeline yet. + +## Better long-term upgrade + +If Codra keeps expanding the shared model surface, the better long-term upgrade is one of: + +- generate TS from Rust protocol definitions +- add more domain-specific sync checks beyond the current four domains +- move protocol mirrors into a schema-first flow diff --git a/package.json b/package.json index 64d4099..c0ff0f6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "scripts": { "dev": "pnpm -r --parallel run dev", - "build": "pnpm -r run build" + "build": "pnpm -r run build", + "check:task-loop-protocol-sync": "node scripts/check-task-loop-protocol-sync.mjs" } } diff --git a/packages/shared/core.ts b/packages/shared/core.ts new file mode 100644 index 0000000..a513490 --- /dev/null +++ b/packages/shared/core.ts @@ -0,0 +1,87 @@ +// Shared types between frontend and backend representation +export interface AgentTask { + id: string; + title: string; + status: "PENDING" | "RUNNING" | "WAITING_APPROVAL" | "COMPLETED" | "FAILED"; + description?: string; +} + +export interface WorkspaceSummary { + id: string; + rootPath: string; + metadata?: Record; +} + +export interface RepoSummary { + workspaceId: string; + name: string; +} + +export interface GitStatusSummary { + isGit: boolean; + branch?: string; + changedFiles: number; +} + +export interface SearchQuery { + pattern: string; + directory?: string; +} + +export interface SearchMatch { + path: string; + lineNumber: number; + preview: string; +} + +export interface FileReadResult { + content: string; +} + +export interface FileWriteRequest { + path: string; + content: string; +} + +export interface FileWriteResult { + success: boolean; + checkpointId?: string; + error?: string; +} + +export interface CommandExecutionRequest { + command: string; + args: string[]; +} + +export interface CommandExecutionResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface CheckpointRecord { + id: string; + workspaceId: string; + timestamp: string; + targetPath: string; + operationType: string; + status: string; +} + +export interface ApprovalRequirement { + id: string; + actionType: string; + description: string; +} + +export interface ApprovalDecision { + requirementId: string; + approved: boolean; +} + +export interface FileEntry { + name: string; + path: string; + isDirectory: boolean; +} diff --git a/packages/shared/executor.ts b/packages/shared/executor.ts new file mode 100644 index 0000000..97ac994 --- /dev/null +++ b/packages/shared/executor.ts @@ -0,0 +1,78 @@ +export type ExecutionStatus = + | "pending" + | "ready" + | "running" + | "waiting_for_approval" + | "paused" + | "blocked" + | "failed" + | "completed" + | "cancelled"; + +export type ExecutionMode = "step_by_step" | "autonomous"; +export type StepExecutionStatus = + | "not_started" + | "context_ready" + | "action_selected" + | "running" + | "awaiting_patch_review" + | "awaiting_approval" + | "applied" + | "verified" + | "failed" + | "skipped"; + +export type ActionKind = + | "inspect_files" + | "search_repo" + | "read_file" + | "propose_edit" + | "apply_edit" + | "run_command" + | "update_docs" + | "git_review" + | "prepare_verify"; + +export type PatchProposalStatus = + | "draft" + | "ready_for_review" + | "approved" + | "rejected" + | "applied" + | "superseded"; + +export interface ExecutionState { + id: string; + planId: string; + status: ExecutionStatus; + mode: ExecutionMode; + currentStepId?: string; +} + +export interface ObservationRecord { + timestamp: string; + message: string; +} + +export interface PatchProposal { + id: string; + stepId: string; + targetFile: string; + rationale: string; + diffContent: string; + status: PatchProposalStatus; + timestamp: string; +} + +export interface StepExecutionRecord { + stepId: string; + status: StepExecutionStatus; + observations: ObservationRecord[]; + pendingPatch?: PatchProposal; +} + +export interface ActionIntent { + kind: ActionKind; + target: string; + reason: string; +} diff --git a/packages/shared/index.ts b/packages/shared/index.ts index 1233532..b30db85 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -1,280 +1,8 @@ -// Shared types between frontend and backend representation -export interface AgentTask { id: string; title: string; status: 'PENDING' | 'RUNNING' | 'WAITING_APPROVAL' | 'COMPLETED' | 'FAILED'; description?: string; } -export interface WorkspaceSummary { id: string; rootPath: string; metadata?: Record; } -export interface RepoSummary { workspaceId: string; name: string; } -export interface GitStatusSummary { isGit: boolean; branch?: string; changedFiles: number; } -export interface SearchQuery { pattern: string; directory?: string; } -export interface SearchMatch { path: string; lineNumber: number; preview: string; } -export interface FileReadResult { content: string; } -export interface FileWriteRequest { path: string; content: string; } -export interface FileWriteResult { success: boolean; checkpointId?: string; error?: string; } -export interface CommandExecutionRequest { command: string; args: string[]; } -export interface CommandExecutionResult { stdout: string; stderr: string; exitCode: number; } -export interface CheckpointRecord { id: string; workspaceId: string; timestamp: string; targetPath: string; operationType: string; status: string; } -export interface ApprovalRequirement { id: string; actionType: string; description: string; } -export interface ApprovalDecision { requirementId: string; approved: boolean; } -export interface FileEntry { name: string; path: string; isDirectory: boolean; } - -// PLANNER ADDITIONS -export type PlanStatus = 'draft' | 'ready_for_review' | 'approved' | 'rejected' | 'superseded' | 'archived'; -export type PlanningMode = 'auto' | 'interactive'; -export type PlanStepStatus = 'pending' | 'running' | 'completed' | 'failed'; -export type PlanStepKind = 'inspect' | 'search' | 'edit' | 'run_command' | 'verify' | 'git_review' | 'browser_task' | 'deploy_prep' | 'doc_update' | 'manual_input'; -export interface TaskRequest { id: string; intent: string; mode: PlanningMode; } -export interface TaskContext { workspaceId: string; workspacePath: string; intent: string; recentSearches: string[]; recentFiles: string[]; } -export interface RiskItem { description: string; severity: 'low' | 'medium' | 'high'; } -export interface AssumptionItem { description: string; confidence: 'low' | 'medium' | 'high'; } -export interface PlanDependency { stepId: string; dependsOn: string; } -export interface PlanStep { id: string; kind: PlanStepKind; title: string; objective: string; status: PlanStepStatus; filesLikelyInvolved: string[]; requiredTools: string[]; } -export interface ArchitectureProposal { id: string; rationale: string; successCriteria: string[]; estimatedImpact: string; tradeoffs: string[]; touchedSubsystems: string[]; } -export interface ExecutionPlan { id: string; taskId: string; status: PlanStatus; title: string; objective: string; steps: PlanStep[]; dependencies: PlanDependency[]; assumptions: AssumptionItem[]; risks: RiskItem[]; architectureProposal?: ArchitectureProposal; } -export interface PlannerOutput { plan: ExecutionPlan; } -export interface PlannerDecision { requiresArchitecture: boolean; reason: string; } - -// EXECUTOR ADDITIONS -export type ExecutionStatus = 'pending' | 'ready' | 'running' | 'waiting_for_approval' | 'paused' | 'blocked' | 'failed' | 'completed' | 'cancelled'; -export type ExecutionMode = 'step_by_step' | 'autonomous'; -export type StepExecutionStatus = 'not_started' | 'context_ready' | 'action_selected' | 'running' | 'awaiting_patch_review' | 'awaiting_approval' | 'applied' | 'verified' | 'failed' | 'skipped'; -export type ActionKind = 'inspect_files' | 'search_repo' | 'read_file' | 'propose_edit' | 'apply_edit' | 'run_command' | 'update_docs' | 'git_review' | 'prepare_verify'; -export type PatchProposalStatus = 'draft' | 'ready_for_review' | 'approved' | 'rejected' | 'applied' | 'superseded'; - -export interface ExecutionState { - id: string; - planId: string; - status: ExecutionStatus; - mode: ExecutionMode; - currentStepId?: string; -} - -export interface ObservationRecord { - timestamp: string; - message: string; -} - -export interface PatchProposal { - id: string; - stepId: string; - targetFile: string; - rationale: string; - diffContent: string; - status: PatchProposalStatus; - timestamp: string; -} - -export interface StepExecutionRecord { - stepId: string; - status: StepExecutionStatus; - observations: ObservationRecord[]; - pendingPatch?: PatchProposal; -} - -export interface ActionIntent { - kind: ActionKind; - target: string; - reason: string; -} - -// VERIFIER ADDITIONS -export type VerificationStatus = 'pending' | 'ready' | 'running' | 'passed' | 'failed' | 'inconclusive' | 'blocked' | 'cancelled'; -export type VerificationCheckKind = 'test_command' | 'lint_command' | 'typecheck_command' | 'build_command' | 'formatting_check' | 'custom_command'; -export type VerificationSeverity = 'low' | 'medium' | 'critical' | 'fatal'; -export type FailureClassification = 'test_failure' | 'lint_failure' | 'type_error' | 'build_error' | 'missing_dependency' | 'environment_issue' | 'command_failure' | 'timeout' | 'unknown'; - -export interface VerificationCheck { - id: string; - kind: VerificationCheckKind; - command: string; - args: string[]; -} - -export interface VerificationFinding { - id: string; - severity: VerificationSeverity; - classification: FailureClassification; - message: string; - affectedFiles: string[]; -} - -export interface RetryRecommendation { - reason: string; - affectedFiles: string[]; - suggestedAction: string; - allowAutoExecution: boolean; -} - -export interface RetryRequest { - id: string; - verificationId: string; - executionId: string; - stepId: string; - failureSummary: string; - findings: VerificationFinding[]; - suggestedScope: string; -} - -export interface VerificationState { - id: string; - executionId: string; - stepId: string; - status: VerificationStatus; - checksConfigured: VerificationCheck[]; - findings: VerificationFinding[]; - retryRecommendation?: RetryRecommendation; - stdout: string; -} - -// INTEGRATION ADDITIONS (REPAIR, BROWSER, DEPLOY) -export type RepairAttemptStatus = 'pending' | 'running' | 'awaiting_approval' | 'applied' | 'verifying' | 'failed' | 'exhausted' | 'completed'; -export type DeployTargetKind = 'node_web_app' | 'static_site' | 'tauri_desktop' | 'rust_service' | 'unknown'; -export type BrowserSessionStatus = 'idle' | 'launching' | 'connecting' | 'ready' | 'navigating' | 'busy' | 'disconnected' | 'failed' | 'closed'; -export type BrowserActionKind = 'open_url' | 'click_selector' | 'type_selector' | 'wait_for_selector' | 'extract_text' | 'capture_screenshot' | 'get_page_state'; -export type BrowserArtifactKind = 'screenshot' | 'page_snapshot' | 'event_log' | 'extracted_text'; - -export interface BrowserTargetInfo { - url: string; - title: string; -} - -export interface BrowserPageState { - url: string; - title: string; - canGoBack: boolean; - canGoForward: boolean; -} - -export interface BrowserActionRequest { - id: string; - kind: BrowserActionKind; - value: string; - textInput?: string; -} - -export interface BrowserActionResult { - actionId: string; - success: boolean; - message: string; - artifactPath?: string; - screenshotBase64?: string; -} - -export interface BrowserEventLogEntry { - timestamp: string; - actionId: string; - kind: BrowserActionKind; - success: boolean; - message: string; -} - -export interface BrowserArtifact { - id: string; - kind: BrowserArtifactKind; - path: string; - timestamp: string; - metadata?: any; -} - -export interface BrowserSessionState { - status: BrowserSessionStatus; - currentTarget?: BrowserTargetInfo; - lastError?: string; - artifacts?: BrowserArtifact[]; - eventLog?: BrowserEventLogEntry[]; -} - -export interface RepairAttempt { - id: string; - verificationId: string; - status: RepairAttemptStatus; - proposedPatch?: PatchProposal; - error?: string; - attemptNumber: number; -} - -export interface DeployPrepSummary { - id: string; - targetKind: DeployTargetKind; - detectedRoots: string[]; - proposedCommands: string[]; - risks: string[]; -} - -export interface AppBootData { - lastWorkspace?: WorkspaceSummary; - activePlan?: ExecutionPlan; - activeExecution?: ExecutionState; - providerConfig?: ProviderConfig; - timeline?: TimelineEvent[]; - safetyMode?: SafetyMode; - runtimeMode?: RuntimeMode; - recoveredFromLegacy: boolean; -} - -// PROVIDER DOMAIN TYPES -export type ProviderKind = 'ollama' | 'openai_compatible' | 'open_ai' | 'anthropic' | 'gemini' | 'bedrock' | 'vertex' | 'mock'; -export type ProviderStatus = 'unconfigured' | 'connecting' | 'connected' | 'failed' | 'degraded'; -export type GenerationMode = 'plan_generation' | 'architecture_generation' | 'step_refinement' | 'patch_rationale' | 'verification_analysis' | 'repair_generation' | 'deploy_prep_reasoning'; - -export interface ProviderConfig { - kind: ProviderKind; - baseUrl: string; - modelId: string; - apiKeySet: boolean; - profileId?: string; - profileName?: string; -} - -export interface ProviderHealthResult { - reachable: boolean; - modelAvailable: boolean; - status: ProviderStatus; - message: string; -} - -export interface ModelDescriptor { - id: string; - name: string; - contextLength?: number; - supportsTools?: boolean; - supportsVision?: boolean; -} - -export interface GenerationRequest { - mode: GenerationMode; - systemPrompt: string; - userPrompt: string; - maxTokens?: number; - temperature?: number; -} - -export interface GenerationResponse { - content: string; - finishReason?: string; - tokenUsage?: TokenUsage; -} - -export interface TokenUsage { - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - - -export type SafetyMode = 'read_only' | 'workspace_write' | 'danger_full_access'; -export type RuntimeMode = 'balanced' | 'local_only' | 'cloud_assisted' | 'research_heavy' | 'browser_heavy'; -export type TimelineSource = 'system' | 'planner' | 'executor' | 'verifier' | 'repair' | 'provider' | 'tool' | 'browser' | 'computer_use' | 'research' | 'deploy' | 'design'; -export interface TimelineEvent { id: string; timestamp: string; source: TimelineSource; title: string; message: string; status: string; } -export interface ProviderProfile { id: string; name: string; config: ProviderConfig; runtimeMode: RuntimeMode; routePlannerModel?: string; routeExecutorModel?: string; routeVerifierModel?: string; routeResearchModel?: string; } -export type ToolCategory = 'filesystem' | 'search' | 'terminal' | 'git' | 'planner' | 'verifier' | 'browser' | 'computer_use' | 'web_research' | 'design' | 'deploy' | 'task'; -export type ToolSafetyLevel = 'read_only' | 'workspace_write' | 'destructive' | 'external_network' | 'computer_control'; -export interface ToolDefinition { name: string; displayName: string; description: string; category: ToolCategory; safetyLevel: ToolSafetyLevel; inputSchema: unknown; } -export interface ToolCallRequest { id: string; toolName: string; arguments: unknown; } -export interface ToolCallResult { id: string; toolName: string; success: boolean; output: unknown; approvalRequired?: ApprovalRequirement; timelineEvent?: TimelineEvent; } -export interface McpServerInfo { name: string; version: string; tools: ToolDefinition[]; } -export interface SkillDescriptor { name: string; path: string; description: string; enabled: boolean; } -export type ComputerUseActionKind = 'list_apps' | 'get_app_state' | 'press_key' | 'type_text' | 'click_target' | 'run_sequence'; -export interface ComputerUseAction { id: string; kind: ComputerUseActionKind; target?: string; text?: string; sequence: ComputerUseAction[]; requiresPermission: boolean; } -export interface ComputerUseResult { actionId: string; success: boolean; message: string; state?: unknown; } -export interface WebResearchRecord { id: string; query: string; title: string; url: string; summary: string; markedRelevant: boolean; timestamp: string; } -export interface DesignToken { name: string; value: string; category: string; description?: string; } -export interface DesignSystemSummary { found: boolean; path?: string; tokens: DesignToken[]; rationale: string; issues: string[]; } -export interface CodraShellData { workspace?: WorkspaceSummary; provider?: ProviderConfig; providerHealth?: ProviderHealthResult; activePlan?: ExecutionPlan; activeExecution?: ExecutionState; tools: ToolDefinition[]; timeline: TimelineEvent[]; browser: BrowserSessionState; research: WebResearchRecord[]; designSystem: DesignSystemSummary; safetyMode: SafetyMode; runtimeMode: RuntimeMode; } +export * from "./core"; +export * from "./planner"; +export * from "./executor"; +export * from "./verifier"; +export * from "./integrations"; +export * from "./provider"; +export * from "./runtime"; +export * from "./task-loop"; diff --git a/packages/shared/integrations.ts b/packages/shared/integrations.ts new file mode 100644 index 0000000..5b94968 --- /dev/null +++ b/packages/shared/integrations.ts @@ -0,0 +1,127 @@ +import type { WorkspaceSummary } from "./core"; +import type { ExecutionState, PatchProposal } from "./executor"; +import type { ExecutionPlan } from "./planner"; +import type { ProviderConfig } from "./provider"; +import type { RuntimeMode, SafetyMode, TimelineEvent } from "./runtime"; + +export type RepairAttemptStatus = + | "pending" + | "running" + | "awaiting_approval" + | "applied" + | "verifying" + | "failed" + | "exhausted" + | "completed"; + +export type DeployTargetKind = + | "node_web_app" + | "static_site" + | "tauri_desktop" + | "rust_service" + | "unknown"; + +export type BrowserSessionStatus = + | "idle" + | "launching" + | "connecting" + | "ready" + | "navigating" + | "busy" + | "disconnected" + | "failed" + | "closed"; + +export type BrowserActionKind = + | "open_url" + | "click_selector" + | "type_selector" + | "wait_for_selector" + | "extract_text" + | "capture_screenshot" + | "get_page_state"; + +export type BrowserArtifactKind = + | "screenshot" + | "page_snapshot" + | "event_log" + | "extracted_text"; + +export interface BrowserTargetInfo { + url: string; + title: string; +} + +export interface BrowserPageState { + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface BrowserActionRequest { + id: string; + kind: BrowserActionKind; + value: string; + textInput?: string; +} + +export interface BrowserActionResult { + actionId: string; + success: boolean; + message: string; + artifactPath?: string; + screenshotBase64?: string; +} + +export interface BrowserEventLogEntry { + timestamp: string; + actionId: string; + kind: BrowserActionKind; + success: boolean; + message: string; +} + +export interface BrowserArtifact { + id: string; + kind: BrowserArtifactKind; + path: string; + timestamp: string; + metadata?: any; +} + +export interface BrowserSessionState { + status: BrowserSessionStatus; + currentTarget?: BrowserTargetInfo; + lastError?: string; + artifacts?: BrowserArtifact[]; + eventLog?: BrowserEventLogEntry[]; +} + +export interface RepairAttempt { + id: string; + verificationId: string; + status: RepairAttemptStatus; + proposedPatch?: PatchProposal; + error?: string; + attemptNumber: number; +} + +export interface DeployPrepSummary { + id: string; + targetKind: DeployTargetKind; + detectedRoots: string[]; + proposedCommands: string[]; + risks: string[]; +} + +export interface AppBootData { + lastWorkspace?: WorkspaceSummary; + activePlan?: ExecutionPlan; + activeExecution?: ExecutionState; + providerConfig?: ProviderConfig; + timeline?: TimelineEvent[]; + safetyMode?: SafetyMode; + runtimeMode?: RuntimeMode; + recoveredFromLegacy: boolean; +} diff --git a/packages/shared/planner.ts b/packages/shared/planner.ts new file mode 100644 index 0000000..60eef70 --- /dev/null +++ b/packages/shared/planner.ts @@ -0,0 +1,91 @@ +export type PlanStatus = + | "draft" + | "ready_for_review" + | "approved" + | "rejected" + | "superseded" + | "archived"; + +export type PlanningMode = "auto" | "interactive"; +export type PlanStepStatus = "pending" | "running" | "completed" | "failed"; +export type PlanStepKind = + | "inspect" + | "search" + | "edit" + | "run_command" + | "verify" + | "git_review" + | "browser_task" + | "deploy_prep" + | "doc_update" + | "manual_input"; + +export interface TaskRequest { + id: string; + intent: string; + mode: PlanningMode; +} + +export interface TaskContext { + workspaceId: string; + workspacePath: string; + intent: string; + recentSearches: string[]; + recentFiles: string[]; +} + +export interface RiskItem { + description: string; + severity: "low" | "medium" | "high"; +} + +export interface AssumptionItem { + description: string; + confidence: "low" | "medium" | "high"; +} + +export interface PlanDependency { + stepId: string; + dependsOn: string; +} + +export interface PlanStep { + id: string; + kind: PlanStepKind; + title: string; + objective: string; + status: PlanStepStatus; + filesLikelyInvolved: string[]; + requiredTools: string[]; +} + +export interface ArchitectureProposal { + id: string; + rationale: string; + successCriteria: string[]; + estimatedImpact: string; + tradeoffs: string[]; + touchedSubsystems: string[]; +} + +export interface ExecutionPlan { + id: string; + taskId: string; + status: PlanStatus; + title: string; + objective: string; + steps: PlanStep[]; + dependencies: PlanDependency[]; + assumptions: AssumptionItem[]; + risks: RiskItem[]; + architectureProposal?: ArchitectureProposal; +} + +export interface PlannerOutput { + plan: ExecutionPlan; +} + +export interface PlannerDecision { + requiresArchitecture: boolean; + reason: string; +} diff --git a/packages/shared/provider.ts b/packages/shared/provider.ts new file mode 100644 index 0000000..aef6ef9 --- /dev/null +++ b/packages/shared/provider.ts @@ -0,0 +1,141 @@ +import type { ApprovalRequirement } from "./core"; +import type { RuntimeMode, TimelineEvent } from "./runtime"; + +export type ProviderKind = + | "ollama" + | "openai_compatible" + | "open_ai" + | "anthropic" + | "gemini" + | "bedrock" + | "vertex" + | "mock"; + +export type ProviderStatus = + | "unconfigured" + | "connecting" + | "connected" + | "failed" + | "degraded"; + +export type GenerationMode = + | "plan_generation" + | "architecture_generation" + | "step_refinement" + | "patch_rationale" + | "verification_analysis" + | "repair_generation" + | "deploy_prep_reasoning"; + +export interface ProviderConfig { + kind: ProviderKind; + baseUrl: string; + modelId: string; + apiKeySet: boolean; + profileId?: string; + profileName?: string; +} + +export interface ProviderHealthResult { + reachable: boolean; + modelAvailable: boolean; + status: ProviderStatus; + message: string; +} + +export interface ModelDescriptor { + id: string; + name: string; + contextLength?: number; + supportsTools?: boolean; + supportsVision?: boolean; +} + +export interface GenerationRequest { + mode: GenerationMode; + systemPrompt: string; + userPrompt: string; + maxTokens?: number; + temperature?: number; +} + +export interface GenerationResponse { + content: string; + finishReason?: string; + tokenUsage?: TokenUsage; +} + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export interface ProviderProfile { + id: string; + name: string; + config: ProviderConfig; + runtimeMode: RuntimeMode; + routePlannerModel?: string; + routeExecutorModel?: string; + routeVerifierModel?: string; + routeResearchModel?: string; +} + +export type ToolCategory = + | "filesystem" + | "search" + | "terminal" + | "git" + | "planner" + | "verifier" + | "browser" + | "computer_use" + | "web_research" + | "design" + | "deploy" + | "task"; + +export type ToolSafetyLevel = + | "read_only" + | "workspace_write" + | "destructive" + | "external_network" + | "computer_control"; + +export interface ToolDefinition { + name: string; + displayName: string; + description: string; + category: ToolCategory; + safetyLevel: ToolSafetyLevel; + inputSchema: unknown; +} + +export interface ToolCallRequest { + id: string; + toolName: string; + arguments: unknown; +} + +export interface ToolCallResult { + id: string; + toolName: string; + success: boolean; + output: unknown; + approvalRequired?: ApprovalRequirement; + timelineEvent?: TimelineEvent; +} + +export interface McpServerInfo { + name: string; + version: string; + tools: ToolDefinition[]; +} + +export interface SkillDescriptor { + name: string; + path: string; + description: string; + enabled: boolean; +} diff --git a/packages/shared/runtime.ts b/packages/shared/runtime.ts new file mode 100644 index 0000000..e0111d1 --- /dev/null +++ b/packages/shared/runtime.ts @@ -0,0 +1,80 @@ +export type SafetyMode = "read_only" | "workspace_write" | "danger_full_access"; + +export type RuntimeMode = + | "balanced" + | "local_only" + | "cloud_assisted" + | "research_heavy" + | "browser_heavy"; + +export type TimelineSource = + | "system" + | "planner" + | "executor" + | "verifier" + | "repair" + | "provider" + | "tool" + | "browser" + | "computer_use" + | "research" + | "deploy" + | "design"; + +export interface TimelineEvent { + id: string; + timestamp: string; + source: TimelineSource; + title: string; + message: string; + status: string; +} + +export type ComputerUseActionKind = + | "list_apps" + | "get_app_state" + | "press_key" + | "type_text" + | "click_target" + | "run_sequence"; + +export interface ComputerUseAction { + id: string; + kind: ComputerUseActionKind; + target?: string; + text?: string; + sequence: ComputerUseAction[]; + requiresPermission: boolean; +} + +export interface ComputerUseResult { + actionId: string; + success: boolean; + message: string; + state?: unknown; +} + +export interface WebResearchRecord { + id: string; + query: string; + title: string; + url: string; + summary: string; + markedRelevant: boolean; + timestamp: string; +} + +export interface DesignToken { + name: string; + value: string; + category: string; + description?: string; +} + +export interface DesignSystemSummary { + found: boolean; + path?: string; + tokens: DesignToken[]; + rationale: string; + issues: string[]; +} diff --git a/packages/shared/task-loop.ts b/packages/shared/task-loop.ts new file mode 100644 index 0000000..b78bc4f --- /dev/null +++ b/packages/shared/task-loop.ts @@ -0,0 +1,107 @@ +export type TaskStatus = + | "draft" + | "planning" + | "awaiting_approval" + | "approved" + | "executing" + | "verifying" + | "repair_planning" + | "awaiting_repair_approval" + | "repairing" + | "completed" + | "failed" + | "cancelled"; + +export interface TaskStep { + id: string; + title: string; + description: string; + status: string; +} + +export interface TaskPlan { + summary: string; + steps: TaskStep[]; + files_to_read: string[]; + files_to_modify: string[]; + commands_to_run: string[]; + risk_level: string; + requires_approval: boolean; +} + +export interface FileChange { + path: string; + change_type: string; + approved: boolean; + applied: boolean; +} + +export interface CommandRun { + command: string; + cwd: string; + status: string; + exit_code?: number; + stdout_preview?: string; + stderr_preview?: string; +} + +export interface VerificationResult { + success: boolean; + summary: string; + errors: string[]; +} + +export interface TaskEvent { + id: string; + task_id: string; + timestamp: string; + event_type: string; + message: string; +} + +export interface DetectedCommand { + command: string; + reason: string; + risk_level: string; + allowed: boolean; +} + +export interface WorkspaceFileNode { + path: string; + kind: "file" | "directory"; + size?: number; + children?: WorkspaceFileNode[]; + language?: string; +} + +export interface WorkspaceContext { + workspace_path: string; + is_git_repo: boolean; + git_branch?: string; + git_status_summary?: string; + detected_stack: string[]; + detected_package_managers: string[]; + detected_config_files: string[]; + suggested_commands: DetectedCommand[]; + file_tree: WorkspaceFileNode[]; + ignored_dirs: string[]; + scanned_at: string; +} + +export interface Task { + id: string; + title: string; + user_prompt: string; + workspace_path: string; + status: TaskStatus; + created_at: string; + updated_at: string; + completed_at?: string; + plan?: TaskPlan; + repair_plan?: TaskPlan; + changed_files: FileChange[]; + commands_run: CommandRun[]; + verification_result?: VerificationResult; + final_report?: string; + error?: string; +} diff --git a/packages/shared/verifier.ts b/packages/shared/verifier.ts new file mode 100644 index 0000000..c25362f --- /dev/null +++ b/packages/shared/verifier.ts @@ -0,0 +1,72 @@ +export type VerificationStatus = + | "pending" + | "ready" + | "running" + | "passed" + | "failed" + | "inconclusive" + | "blocked" + | "cancelled"; + +export type VerificationCheckKind = + | "test_command" + | "lint_command" + | "typecheck_command" + | "build_command" + | "formatting_check" + | "custom_command"; + +export type VerificationSeverity = "low" | "medium" | "critical" | "fatal"; +export type FailureClassification = + | "test_failure" + | "lint_failure" + | "type_error" + | "build_error" + | "missing_dependency" + | "environment_issue" + | "command_failure" + | "timeout" + | "unknown"; + +export interface VerificationCheck { + id: string; + kind: VerificationCheckKind; + command: string; + args: string[]; +} + +export interface VerificationFinding { + id: string; + severity: VerificationSeverity; + classification: FailureClassification; + message: string; + affectedFiles: string[]; +} + +export interface RetryRecommendation { + reason: string; + affectedFiles: string[]; + suggestedAction: string; + allowAutoExecution: boolean; +} + +export interface RetryRequest { + id: string; + verificationId: string; + executionId: string; + stepId: string; + failureSummary: string; + findings: VerificationFinding[]; + suggestedScope: string; +} + +export interface VerificationState { + id: string; + executionId: string; + stepId: string; + status: VerificationStatus; + checksConfigured: VerificationCheck[]; + findings: VerificationFinding[]; + retryRecommendation?: RetryRecommendation; + stdout: string; +} diff --git a/scripts/check-task-loop-protocol-sync.mjs b/scripts/check-task-loop-protocol-sync.mjs new file mode 100644 index 0000000..8f90dca --- /dev/null +++ b/scripts/check-task-loop-protocol-sync.mjs @@ -0,0 +1,311 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const rustPath = path.join(repoRoot, "crates/codra-protocol/src/lib.rs"); + +const rustSource = readFileSync(rustPath, "utf8"); + +const RAW = "raw"; +const CAMEL = "camel"; + +const DOMAIN_CONFIGS = [ + { + label: "task-loop", + tsPath: path.join(repoRoot, "packages/shared/task-loop.ts"), + fieldNameStyle: RAW, + unions: ["TaskStatus"], + interfaces: [ + "Task", + "TaskPlan", + "TaskStep", + "FileChange", + "CommandRun", + "VerificationResult", + "TaskEvent", + "WorkspaceFileNode", + "DetectedCommand", + "WorkspaceContext", + ], + inlineUnions: [ + { + rustEnum: "FileNodeKind", + tsInterface: "WorkspaceFileNode", + tsField: "kind", + }, + ], + }, + { + label: "planner", + tsPath: path.join(repoRoot, "packages/shared/planner.ts"), + fieldNameStyle: CAMEL, + unions: ["PlanStatus", "PlanningMode", "PlanStepStatus", "PlanStepKind"], + interfaces: [ + "TaskRequest", + "TaskContext", + "RiskItem", + "AssumptionItem", + "PlanDependency", + "PlanStep", + "ArchitectureProposal", + "ExecutionPlan", + "PlannerOutput", + "PlannerDecision", + ], + }, + { + label: "executor", + tsPath: path.join(repoRoot, "packages/shared/executor.ts"), + fieldNameStyle: CAMEL, + unions: [ + "ExecutionStatus", + "ExecutionMode", + "StepExecutionStatus", + "ActionKind", + "PatchProposalStatus", + ], + interfaces: [ + "ExecutionState", + "ObservationRecord", + "PatchProposal", + "StepExecutionRecord", + "ActionIntent", + ], + }, + { + label: "verifier", + tsPath: path.join(repoRoot, "packages/shared/verifier.ts"), + fieldNameStyle: CAMEL, + unions: [ + "VerificationStatus", + "VerificationCheckKind", + "VerificationSeverity", + "FailureClassification", + ], + interfaces: [ + "VerificationCheck", + "VerificationFinding", + "RetryRecommendation", + "RetryRequest", + "VerificationState", + ], + }, +]; + +function parseRustStruct(name) { + const match = rustSource.match( + new RegExp(`pub struct ${name} \\{([\\s\\S]*?)\\n\\}`, "m"), + ); + if (!match) return null; + + return [...match[1].matchAll(/^\s*pub\s+(\w+):\s+([^,]+),$/gm)].map((m) => ({ + name: m[1], + type: m[2].trim(), + })); +} + +function parseRustEnumVariants(name) { + const match = rustSource.match( + new RegExp(`pub enum ${name} \\{([\\s\\S]*?)\\n\\}`, "m"), + ); + if (!match) return null; + + return [...match[1].matchAll(/^\s*(\w+),$/gm)].map((m) => m[1]); +} + +function parseTsInterface(tsSource, name) { + const match = tsSource.match( + new RegExp(`export interface ${name} \\{([\\s\\S]*?)\\n\\}`, "m"), + ); + if (!match) return null; + + return [...match[1].matchAll(/^\s*(\w+)(\?)?:\s+([^;]+);$/gm)].map((m) => ({ + name: m[1], + optional: m[2] === "?", + type: m[3].trim(), + })); +} + +function parseTsUnion(tsSource, name) { + const match = tsSource.match( + new RegExp(`export type ${name} =([\\s\\S]*?);`, "m"), + ); + if (!match) return null; + + return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); +} + +function toSnakeCase(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +function snakeToCamel(value) { + return value.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); +} + +function mapRustFieldName(name, style) { + if (style === CAMEL) { + return snakeToCamel(name); + } + return name; +} + +function compareUnion(tsSource, rustEnumName, issuePrefix, issues) { + const rustVariants = parseRustEnumVariants(rustEnumName); + const tsVariants = parseTsUnion(tsSource, rustEnumName); + + if (!rustVariants) { + issues.push(`${issuePrefix} missing Rust enum: ${rustEnumName}`); + return; + } + if (!tsVariants) { + issues.push(`${issuePrefix} missing TS union: ${rustEnumName}`); + return; + } + + const expected = rustVariants.map(toSnakeCase); + const missingInTs = expected.filter( + (variant) => !tsVariants.includes(variant), + ); + const extraInTs = tsVariants.filter((variant) => !expected.includes(variant)); + + if (missingInTs.length) { + issues.push( + `${issuePrefix} ${rustEnumName} missing TS variants: ${missingInTs.join(", ")}`, + ); + } + if (extraInTs.length) { + issues.push( + `${issuePrefix} ${rustEnumName} has extra TS variants: ${extraInTs.join(", ")}`, + ); + } +} + +function compareInterface( + tsSource, + structName, + fieldNameStyle, + issuePrefix, + issues, +) { + const rustFields = parseRustStruct(structName); + const tsFields = parseTsInterface(tsSource, structName); + + if (!rustFields) { + issues.push(`${issuePrefix} missing Rust struct: ${structName}`); + return; + } + if (!tsFields) { + issues.push(`${issuePrefix} missing TS interface: ${structName}`); + return; + } + + const rustFieldNames = rustFields.map((field) => + mapRustFieldName(field.name, fieldNameStyle), + ); + const tsFieldNames = tsFields.map((field) => field.name); + + const missingInTs = rustFieldNames.filter( + (name) => !tsFieldNames.includes(name), + ); + const extraInTs = tsFieldNames.filter( + (name) => !rustFieldNames.includes(name), + ); + + if (missingInTs.length) { + issues.push( + `${issuePrefix} ${structName} missing TS fields: ${missingInTs.join(", ")}`, + ); + } + if (extraInTs.length) { + issues.push( + `${issuePrefix} ${structName} has extra TS fields: ${extraInTs.join(", ")}`, + ); + } +} + +function compareInlineUnion(tsSource, config, issuePrefix, issues) { + const rustVariants = parseRustEnumVariants(config.rustEnum)?.map(toSnakeCase); + const tsFields = parseTsInterface(tsSource, config.tsInterface) ?? []; + const targetField = tsFields.find((field) => field.name === config.tsField); + + if (!rustVariants) { + issues.push(`${issuePrefix} missing Rust enum: ${config.rustEnum}`); + return; + } + if (!targetField) { + issues.push( + `${issuePrefix} ${config.tsInterface} missing TS field: ${config.tsField}`, + ); + return; + } + + const tsVariants = [...targetField.type.matchAll(/"([^"]+)"/g)].map( + (m) => m[1], + ); + const missingInTs = rustVariants.filter( + (variant) => !tsVariants.includes(variant), + ); + const extraInTs = tsVariants.filter( + (variant) => !rustVariants.includes(variant), + ); + + if (missingInTs.length) { + issues.push( + `${issuePrefix} ${config.tsInterface}.${config.tsField} missing TS variants: ${missingInTs.join(", ")}`, + ); + } + if (extraInTs.length) { + issues.push( + `${issuePrefix} ${config.tsInterface}.${config.tsField} has extra TS variants: ${extraInTs.join(", ")}`, + ); + } +} + +const issues = []; +const checkedSummary = []; + +for (const domain of DOMAIN_CONFIGS) { + const tsSource = readFileSync(domain.tsPath, "utf8"); + const issuePrefix = `[${domain.label}]`; + + for (const unionName of domain.unions ?? []) { + compareUnion(tsSource, unionName, issuePrefix, issues); + } + + for (const interfaceName of domain.interfaces ?? []) { + compareInterface( + tsSource, + interfaceName, + domain.fieldNameStyle ?? RAW, + issuePrefix, + issues, + ); + } + + for (const inlineUnion of domain.inlineUnions ?? []) { + compareInlineUnion(tsSource, inlineUnion, issuePrefix, issues); + } + + checkedSummary.push( + `${domain.label}: ${(domain.unions ?? []).length} unions, ${(domain.interfaces ?? []).length} interfaces${domain.inlineUnions?.length ? `, ${domain.inlineUnions.length} inline union checks` : ""}`, + ); +} + +if (issues.length > 0) { + console.error("Shared protocol sync check failed:\n"); + for (const issue of issues) { + console.error(`- ${issue}`); + } + process.exit(1); +} + +console.log("Shared protocol sync check passed."); +for (const line of checkedSummary) { + console.log(`- ${line}`); +}