diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index 967601ecb4da..bef8200bd351 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { ProjectUpdate } from '../../../shared/rpc-contract/project-runtime-params' import { isTuiAgent } from '../../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' @@ -60,7 +61,8 @@ const LocalWindowsRuntimePreferenceIpcArgs = z.discriminatedUnion('kind', [ export const ProjectUpdateIpcArgs = z.object({ projectId: z.string().min(1), updates: z.object({ - localWindowsRuntimePreference: LocalWindowsRuntimePreferenceIpcArgs.optional() + localWindowsRuntimePreference: LocalWindowsRuntimePreferenceIpcArgs.optional(), + coordination: ProjectUpdate.shape.updates.shape.coordination }) }) diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index 305ba53d8690..8a67540f6707 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -49,6 +49,19 @@ function makeState(overrides: Partial): PersistedState { } describe('rebuildRepoBackedProjectState', () => { + it('preserves saved project context when rebuilding transferred profile state', () => { + const coordination = { goal: 'Ship', instructions: 'Review first', revision: 3 } + const rebuilt = rebuildRepoBackedProjectState( + makeState({ + repos: [makeRepo()], + projects: [makeProject({ id: 'repo:r1', sourceRepoIds: ['r1'], coordination })] + }) + ) + expect( + rebuilt.projects?.find((project) => project.sourceRepoIds.includes('r1'))?.coordination + ).toEqual(coordination) + }) + it('carries project state and independent setups across a repo remote identity change', () => { const originProjectId = 'git:git.example.com/acme/app' const rebuilt = rebuildRepoBackedProjectState( diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index 605c258f4f7a..3c8838e8e5de 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -1,3 +1,4 @@ +import { applyProjectCoordinationUpdate } from '../../../shared/project-coordination' import type { Project, ProjectHostSetup, @@ -87,6 +88,12 @@ export class ProjectHostPersistenceOperations { if (!project) { return null } + if (updates.coordination) { + project.coordination = applyProjectCoordinationUpdate( + project.coordination, + updates.coordination + ) + } if ('localWindowsRuntimePreference' in updates) { for (const repoId of project.sourceRepoIds) { this.operations.bumpLocalWorktreeScanGeneration(repoId) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 02ae6a335f22..bce915017568 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -1,3 +1,4 @@ +import { ProjectCoordinationConflictError } from '../../../shared/project-coordination' import { describe, expect, it } from 'vitest' import { mapRuntimeError } from './errors' import { @@ -24,6 +25,12 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it('preserves a project context revision conflict across RPC', () => { + expect( + mapRuntimeError('request', { runtimeId: 'host' }, new ProjectCoordinationConflictError()) + ).toMatchObject({ ok: false, error: { code: 'project_coordination_conflict' } }) + }) + it('preserves the stable skill failure category and retryability across RPC', () => { expect( mapRuntimeError( diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ec1d40081c80..feea283b9f2f 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -25,6 +25,7 @@ import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owne import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate' import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth' import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/worktree-create-collision' +import { PROJECT_COORDINATION_CONFLICT_CODE } from '../../../shared/project-coordination' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -83,6 +84,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) const LINEAR_PASSTHROUGH_CODES: ReadonlySet = new Set(LINEAR_ERROR_CODES) const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ + PROJECT_COORDINATION_CONFLICT_CODE, WORKTREE_CREATE_COLLISION_CODE, 'worktree_id_requires_full_path', 'run_not_found', diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index 3ce49d84bf6b..aeaf4bc75c5d 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -12,6 +12,10 @@ import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip' import type { AppChromeLayout } from './use-app-chrome-layout' import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' +const ManagementOverviewPage = lazy( + () => import('../components/management-overview/ManagementOverviewPage') +) +const ProjectHomePage = lazy(() => import('../components/project-home/ProjectHomePage')) const Landing = lazy(() => import('../components/Landing')) const WorktreeCreationPanel = lazy( () => import('../components/worktree-creation/WorktreeCreationPanel') @@ -68,6 +72,8 @@ function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout return ( <> + {activeView === 'project-home' ? : null} + {activeView === 'management-overview' ? : null} {activeView === 'settings' ? : null} {activeView === 'skills' ? : null} {activeView === 'artifacts' ? : null} diff --git a/src/renderer/src/components/management-overview/ManagementOverviewNav.tsx b/src/renderer/src/components/management-overview/ManagementOverviewNav.tsx new file mode 100644 index 000000000000..1a6136139dff --- /dev/null +++ b/src/renderer/src/components/management-overview/ManagementOverviewNav.tsx @@ -0,0 +1,28 @@ +import { isWebClientLocation } from '@/lib/web-client-location' +import { LayoutDashboard } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function ManagementOverviewNav(): React.JSX.Element | null { + const active = useAppStore((state) => state.activeView === 'management-overview') + if (isWebClientLocation()) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/management-overview/ManagementOverviewPage.tsx b/src/renderer/src/components/management-overview/ManagementOverviewPage.tsx new file mode 100644 index 000000000000..4f78da47534c --- /dev/null +++ b/src/renderer/src/components/management-overview/ManagementOverviewPage.tsx @@ -0,0 +1,159 @@ +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { translate } from '@/i18n/i18n' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useLiveDashboardSnapshot } from '../dashboard/useLiveDashboardSnapshot' +import { revealDashboardAgent } from '../dashboard/reveal-dashboard-agent' +import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' +import { overviewHosts } from './overview-hosts' +import { OverviewProjects } from './OverviewProjects' +import { OverviewRuns } from './OverviewRuns' + +function OverviewAgents(): React.JSX.Element { + const snapshot = useLiveDashboardSnapshot() + return ( + useAppStore.getState().acknowledgeAgents([key])} + onRevealAgent={revealDashboardAgent} + /> + ) +} + +export default function ManagementOverviewPage(): React.JSX.Element { + const environments = useAppStore((state) => state.runtimeEnvironments) + const statuses = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const sshLabels = useAppStore((state) => state.sshTargetLabels) + const sshStates = useAppStore((state) => state.sshConnectionStates) + const profile = useAppStore((state) => state.activeOrcaProfileId) + const switching = useAppStore((state) => state.orcaProfileSwitching) + const hosts = overviewHosts({ + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: statuses, + sshTargetLabels: sshLabels, + sshConnectionStates: sshStates + }) + const scope = JSON.stringify([ + profile, + hosts.map((host) => [host.key, host.pairingRevision, host.state]) + ]) + if (isWebClientLocation()) { + return ( +

+ {translate( + 'managementOverview.desktop', + 'Open Overview in the desktop app to see all connected hosts.' + )} +

+ ) + } + if (switching) { + return ( +

+ {translate('managementOverview.switching', 'Switching profile…')} +

+ ) + } + return ( +
+
+
+

+ {translate('managementOverview.title', 'Overview')} +

+

+ {translate( + 'managementOverview.description', + 'Projects and execution across your connected hosts. Plan and triage work in Linear.' + )} +

+
+ + + +
+
+ {hosts.some((host) => host.state !== 'connected') ? ( +

+ {translate( + 'managementOverview.incomplete', + 'Some hosts are unavailable. Projects and agent activity may be incomplete; check Hosts for connection status.' + )} +

+ ) : null} + + + + {translate('managementOverview.projects', 'Projects')} + + + {translate('managementOverview.agents', 'Agents')} + + {translate('managementOverview.runs', 'Runs')} + + {translate('managementOverview.hosts', 'Hosts')} + + + + + + + + + + + + +
+ {hosts.map((host) => ( +
+
+

{host.label}

+

{host.stateLabel}

+
+
+ ))} +
+

+ {translate( + 'managementOverview.coverage', + 'Unavailable hosts may still have running work. Their current execution state is unverifiable.' + )} +

+
+ + +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/management-overview/OverviewProjects.tsx b/src/renderer/src/components/management-overview/OverviewProjects.tsx new file mode 100644 index 000000000000..2e1434f76812 --- /dev/null +++ b/src/renderer/src/components/management-overview/OverviewProjects.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useAppStore } from '@/store' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import type { OverviewHost } from './overview-hosts' + +export function OverviewProjects({ hosts }: { hosts: OverviewHost[] }): React.JSX.Element { + const repos = useAppStore((state) => state.repos) + const folders = useAppStore((state) => state.folderWorkspaces) + const [query, setQuery] = useState('') + const matches = (name: string) => + name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) + const shownRepos = repos.filter((repo) => matches(repo.displayName)) + const shownFolders = folders.filter((folder) => !folder.isArchived && matches(folder.name)) + return ( +
+ + setQuery(event.target.value)} + /> + {!shownRepos.length && !shownFolders.length ? ( +

+ {translate('managementOverview.noProjects', 'No matching projects or folders.')} +

+ ) : null} +
+ {shownRepos.map((repo) => { + const host = getRepoExecutionHostId(repo) + return ( +
+
+

{repo.displayName}

+

+ {hosts.find((entry) => entry.key === host)?.label ?? host} · {repo.path} +

+
+ +
+ ) + })} + {shownFolders.map((folder) => { + const workspace = folderWorkspaceToWorktree(folder) + return ( +
+
+

{folder.name}

+

+ {hosts.find((entry) => entry.key === workspace.hostId)?.label ?? workspace.hostId}{' '} + · {folder.folderPath} +

+
+ +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/management-overview/OverviewRunDetails.tsx b/src/renderer/src/components/management-overview/OverviewRunDetails.tsx new file mode 100644 index 000000000000..d285c44ae480 --- /dev/null +++ b/src/renderer/src/components/management-overview/OverviewRunDetails.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { readOverviewWorkers, type OverviewRun, type OverviewWorkerPage } from './overview-run-api' +import type { OverviewHost } from './overview-hosts' + +export function OverviewRunDetails({ + host, + run +}: { + host: OverviewHost + run: OverviewRun +}): React.JSX.Element { + const [request, setRequest] = useState<{ cursor: string | undefined; revision: number }>({ + cursor: undefined, + revision: 0 + }) + const [workers, setWorkers] = useState([]) + const [cursor, setCursor] = useState(null) + const [busy, setBusy] = useState(true) + const [error, setError] = useState('') + const [partial, setPartial] = useState(false) + useEffect(() => { + let cancelled = false + setBusy(true) + setError('') + void readOverviewWorkers(host, run.id, request.cursor) + .then((page) => { + if (cancelled) { + return + } + setWorkers((previous) => + request.cursor + ? [ + ...new Map( + [...previous, ...page.workers].map((worker) => [worker.dispatchId, worker]) + ).values() + ] + : page.workers + ) + setPartial( + (previous) => + Boolean(page.partialHostErrors?.length) || (Boolean(request.cursor) && previous) + ) + setCursor(page.page.hasMore ? page.page.nextCursor : null) + }) + .catch((cause: unknown) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : 'Could not read workers.') + } + }) + .finally(() => { + if (!cancelled) { + setBusy(false) + } + }) + return () => { + cancelled = true + } + }, [host, run.id, request]) + return ( +
+

{run.objective}

+

+ {host.label} · {run.id} +

+ + {busy ? ( +

+ {translate('managementOverview.reading', 'Reading…')} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {partial ? ( +

+ {translate( + 'managementOverview.partialWorkers', + 'Some worker hosts are unavailable. These observations are incomplete.' + )} +

+ ) : null} + {!busy && !error && !partial && !workers.length ? ( +

+ {translate('managementOverview.noWorkers', 'No workers recorded for this Run.')} +

+ ) : null} + {workers.map((worker) => ( +
+

{worker.taskId}

+

+ {worker.projection.stage.worker ?? worker.projection.stage.dispatch} ·{' '} + {worker.projection.outcome} +

+

+ {translate('managementOverview.process', 'Process')}:{' '} + {worker.projection.liveness.verdict} ·{' '} + {translate('managementOverview.resource', 'Resource')}:{' '} + {worker.projection.resource.state} +

+
+ ))} + {cursor ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/management-overview/OverviewRuns.tsx b/src/renderer/src/components/management-overview/OverviewRuns.tsx new file mode 100644 index 000000000000..b7f3400ec362 --- /dev/null +++ b/src/renderer/src/components/management-overview/OverviewRuns.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' +import { readOverviewRuns, type OverviewRun } from './overview-run-api' +import type { OverviewHost } from './overview-hosts' +import { OverviewRunDetails } from './OverviewRunDetails' + +type HostRuns = { + host: OverviewHost + runs: OverviewRun[] + cursor: string | null + error: string | null +} +export function OverviewRuns({ hosts }: { hosts: OverviewHost[] }): React.JSX.Element { + const [pages, setPages] = useState([]) + const [busy, setBusy] = useState(false) + const [selected, setSelected] = useState<{ host: OverviewHost; run: OverviewRun } | null>(null) + const alive = useRef(true) + const locked = useRef(false) + useEffect(() => { + alive.current = true + return () => { + alive.current = false + } + }, []) + async function refresh(): Promise { + if (locked.current) { + return + } + locked.current = true + setBusy(true) + setSelected(null) + const next = await mapWithConcurrency( + hosts.filter((host) => !host.directSsh), + 4, + async (host): Promise => { + try { + if (host.state !== 'connected') { + throw new Error('Host is unavailable. Run coverage is incomplete.') + } + const page = await readOverviewRuns(host) + return { host, runs: page.runs, cursor: page.nextCursor, error: null } + } catch (error) { + return { + host, + runs: [], + cursor: null, + error: error instanceof Error ? error.message : 'Could not read Runs.' + } + } + } + ) + if (alive.current) { + setPages(next) + setBusy(false) + } + locked.current = false + } + async function more(page: HostRuns): Promise { + if (!page.cursor || locked.current) { + return + } + locked.current = true + setBusy(true) + try { + const next = await readOverviewRuns(page.host, page.cursor) + if (alive.current) { + setPages((previous) => + previous.map((entry) => + entry.host.key === page.host.key + ? { + ...entry, + runs: [ + ...new Map([...entry.runs, ...next.runs].map((run) => [run.id, run])).values() + ], + cursor: next.nextCursor, + error: null + } + : entry + ) + ) + } + } catch (error) { + if (alive.current) { + setPages((previous) => + previous.map((entry) => + entry.host.key === page.host.key + ? { ...entry, error: error instanceof Error ? error.message : 'Could not read Runs.' } + : entry + ) + ) + } + } finally { + locked.current = false + if (alive.current) { + setBusy(false) + } + } + } + return ( +
+
+

+ {translate( + 'managementOverview.runRead', + 'Read Run records from each available runtime. Direct SSH work is managed by its client runtime.' + )} +

+ +
+ {pages.map((page) => ( +
+

{page.host.label}

+ {page.error ? ( +

+ {page.error} +

+ ) : null} + {!page.error && !page.runs.length ? ( +

+ {translate('managementOverview.noRuns', 'No Run records on this host.')} +

+ ) : null} + {page.runs.map((run) => ( +
+
+

{run.objective}

+

+ {run.id} · {run.created_at} +

+
+ +
+ ))} + {page.cursor ? ( + + ) : null} +
+ ))} + {selected ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/management-overview/overview-hosts.test.ts b/src/renderer/src/components/management-overview/overview-hosts.test.ts new file mode 100644 index 000000000000..21a230400af9 --- /dev/null +++ b/src/renderer/src/components/management-overview/overview-hosts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { overviewHosts } from './overview-hosts' + +describe('Overview host inventory', () => { + it('includes saved but unobserved runtimes without claiming they are connected', () => { + const hosts = overviewHosts({ + runtimeEnvironments: [ + { + id: 'host/one', + name: 'Build server', + createdAt: 1, + updatedAt: 1, + pairingRevision: 2, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'ws' + } + ], + runtimeStatusByEnvironmentId: new Map(), + sshTargetLabels: new Map([ + ['ssh/one', 'SSH server'], + ['runtime-ssh-internal', 'Internal tunnel'] + ]), + sshConnectionStates: new Map() + }) + expect(hosts.map((host) => [host.key, host.state])).toEqual([ + ['local', 'connected'], + ['runtime:host%2Fone', 'checking'], + ['ssh:ssh%2Fone', 'disconnected'] + ]) + expect(hosts[1]?.pairingRevision).toBe(2) + expect(hosts[2]?.directSsh).toBe(true) + }) +}) diff --git a/src/renderer/src/components/management-overview/overview-hosts.ts b/src/renderer/src/components/management-overview/overview-hosts.ts new file mode 100644 index 000000000000..95faba8f66a3 --- /dev/null +++ b/src/renderer/src/components/management-overview/overview-hosts.ts @@ -0,0 +1,65 @@ +import { translate } from '@/i18n/i18n' +import { getRuntimeServerConnectionLabel } from '../settings/runtime-environment-host-details' +import { getSshStatusLabel } from '../new-workspace/new-workspace-composer-ssh-status' +import { + toRuntimeExecutionHostId, + toSshExecutionHostId, + isRuntimeOwnedSshTargetId +} from '../../../../shared/execution-host' +import type { AppState } from '@/store/types' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +export type OverviewHost = { + key: string + label: string + state: string + stateLabel: string + target: RuntimeClientTarget + pairingRevision?: number + directSsh: boolean +} + +export function overviewHosts( + state: Pick< + AppState, + | 'runtimeEnvironments' + | 'runtimeStatusByEnvironmentId' + | 'sshTargetLabels' + | 'sshConnectionStates' + > +): OverviewHost[] { + return [ + { + key: 'local', + label: translate('managementOverview.localHost', 'This computer'), + state: 'connected', + stateLabel: getRuntimeServerConnectionLabel('connected'), + target: { kind: 'local' }, + directSsh: false + }, + ...state.runtimeEnvironments.map((environment): OverviewHost => ({ + key: toRuntimeExecutionHostId(environment.id), + label: environment.name, + state: runtimeHostConnectionStateForEntry( + state.runtimeStatusByEnvironmentId.get(environment.id) + ), + stateLabel: getRuntimeServerConnectionLabel( + runtimeHostConnectionStateForEntry(state.runtimeStatusByEnvironmentId.get(environment.id)) + ), + target: { kind: 'environment', environmentId: environment.id }, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + directSsh: false + })), + ...[...state.sshTargetLabels] + .filter(([id]) => !isRuntimeOwnedSshTargetId(id)) + .map(([id, label]): OverviewHost => ({ + key: toSshExecutionHostId(id), + label, + state: state.sshConnectionStates.get(id)?.status ?? 'disconnected', + stateLabel: getSshStatusLabel(state.sshConnectionStates.get(id)?.status ?? 'disconnected'), + target: { kind: 'local' }, + directSsh: true + })) + ] +} diff --git a/src/renderer/src/components/management-overview/overview-run-api.test.ts b/src/renderer/src/components/management-overview/overview-run-api.test.ts new file mode 100644 index 000000000000..b05b1fa699b2 --- /dev/null +++ b/src/renderer/src/components/management-overview/overview-run-api.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readOverviewRuns, readOverviewWorkers } from './overview-run-api' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import type { OverviewHost } from './overview-hosts' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc })) +const host: OverviewHost = { + key: 'runtime:remote', + label: 'Remote', + state: 'connected', + stateLabel: 'Connected', + target: { kind: 'environment', environmentId: 'remote' }, + pairingRevision: 1, + directSsh: false +} +const empty = { runs: [], nextCursor: null } + +describe('Overview owner reads', () => { + beforeEach(() => { + rpc.mockReset() + replaceRuntimeEnvironmentRevisions([{ id: 'remote', createdAt: 1 }]) + }) + afterEach(() => vi.useRealTimers()) + it('pins bounded pagination to the selected owner', async () => { + rpc.mockResolvedValue(empty) + await expect(readOverviewRuns(host, 'next')).resolves.toEqual(empty) + expect(rpc).toHaveBeenCalledWith( + host.target, + 'orchestration.runList', + { limit: 20, cursor: 'next' }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 1 }) + ) + }) + it('rejects a replaced pairing before and after an in-flight read', async () => { + rpc.mockImplementation(async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'remote', createdAt: 2 }]) + return empty + }) + await expect(readOverviewRuns(host)).rejects.toThrow('pairing changed') + rpc.mockClear() + await expect(readOverviewRuns(host)).rejects.toThrow('pairing changed') + expect(rpc).not.toHaveBeenCalled() + }) + it('never substitutes local execution for a direct SSH owner', async () => { + await expect( + readOverviewRuns({ ...host, target: { kind: 'local' }, directSsh: true }) + ).rejects.toThrow('owning runtime') + expect(rpc).not.toHaveBeenCalled() + }) + it('preserves unavailable workers as unverifiable and includes coverage errors', async () => { + const page = { + workers: [ + { + dispatchId: 'dispatch', + taskId: 'task', + projection: { + outcome: 'outcome_unknown', + liveness: { verdict: 'unverifiable' }, + stage: { worker: null, dispatch: 'start_unknown' }, + resource: { state: 'absent' } + } + } + ], + page: { nextCursor: null, hasMore: false }, + partialHostErrors: [{ code: 'unavailable' }] + } + rpc.mockResolvedValue(page) + await expect(readOverviewWorkers(host, 'run')).resolves.toEqual(page) + expect(rpc).toHaveBeenCalledWith( + host.target, + 'orchestration.workerList', + { run: 'run', limit: 20, paginate: true, includeRemote: true }, + expect.any(Object) + ) + }) + it('reports an unsupported old host as an error rather than an empty fleet', async () => { + rpc.mockRejectedValue(new Error('method_not_found')) + await expect(readOverviewRuns(host)).rejects.toThrow('method_not_found') + expect(rpc).toHaveBeenCalledTimes(1) + }) + it('bounds a stalled local read without inventing an empty result', async () => { + vi.useFakeTimers() + rpc.mockReturnValue(new Promise(() => {})) + const result = expect(readOverviewRuns({ ...host, target: { kind: 'local' } })).rejects.toThrow( + 'Coverage is incomplete' + ) + await vi.advanceTimersByTimeAsync(9000) + await result + }) +}) diff --git a/src/renderer/src/components/management-overview/overview-run-api.ts b/src/renderer/src/components/management-overview/overview-run-api.ts new file mode 100644 index 000000000000..cdb9d64c5c92 --- /dev/null +++ b/src/renderer/src/components/management-overview/overview-run-api.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { withTimeout } from '../../../../shared/promise-timeout-fallback' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { OverviewHost } from './overview-hosts' + +export const OverviewRun = z.object({ + id: z.string(), + objective: z.string(), + created_at: z.string(), + coordinator_handle: z.string().nullable() +}) +export type OverviewRun = z.infer +const RunPage = z.object({ runs: z.array(OverviewRun), nextCursor: z.string().nullable() }) +const WorkerPage = z.object({ + workers: z.array( + z.object({ + dispatchId: z.string(), + taskId: z.string(), + projection: z.object({ + outcome: z.string(), + liveness: z.object({ verdict: z.enum(['live', 'unverifiable', 'exited']) }), + stage: z.object({ worker: z.string().nullable(), dispatch: z.string() }), + resource: z.object({ state: z.string() }) + }) + }) + ), + page: z.object({ nextCursor: z.string().nullable(), hasMore: z.boolean() }), + partialHostErrors: z.array(z.unknown()).optional() +}) +export type OverviewWorkerPage = z.infer +export function assertOverviewHostCurrent(host: OverviewHost): void { + if (host.directSsh) { + throw new Error('Read SSH work through its owning runtime.') + } + if ( + host.target.kind === 'environment' && + getRuntimeEnvironmentRevision(host.target.environmentId) !== host.pairingRevision + ) { + throw new Error('Host pairing changed. Refresh Overview.') + } +} +export async function readOverviewRuns(host: OverviewHost, cursor?: string) { + assertOverviewHostCurrent(host) + const result = RunPage.parse( + await boundedRead( + callRuntimeRpc( + host.target, + 'orchestration.runList', + { limit: 20, ...(cursor ? { cursor } : {}) }, + { timeoutMs: 5000, expectedEnvironmentPairingRevision: host.pairingRevision } + ), + 9000 + ) + ) + assertOverviewHostCurrent(host) + return result +} + +export async function readOverviewWorkers(host: OverviewHost, runId: string, cursor?: string) { + assertOverviewHostCurrent(host) + const result = WorkerPage.parse( + await boundedRead( + callRuntimeRpc( + host.target, + 'orchestration.workerList', + { + run: runId, + limit: 20, + paginate: true, + includeRemote: true, + ...(cursor ? { cursor } : {}) + }, + { timeoutMs: 8000, expectedEnvironmentPairingRevision: host.pairingRevision } + ), + 9000 + ) + ) + assertOverviewHostCurrent(host) + return result +} + +async function boundedRead(promise: Promise, timeoutMs: number): Promise { + type Result = { value: unknown } | { error: unknown } | { timeout: true } + const result = await withTimeout( + promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ), + timeoutMs, + { timeout: true } + ) + if ('timeout' in result) { + throw new Error('Host did not respond. Coverage is incomplete.') + } + if ('error' in result) { + throw result.error + } + return result.value +} diff --git a/src/renderer/src/components/project-home/ProjectContextEditor.tsx b/src/renderer/src/components/project-home/ProjectContextEditor.tsx new file mode 100644 index 000000000000..7be6c8178edd --- /dev/null +++ b/src/renderer/src/components/project-home/ProjectContextEditor.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { translate } from '@/i18n/i18n' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { loadProject, saveProject, type ProjectContext } from './project-home-api' + +export function ProjectContextEditor({ + project, + target, + repoId, + draftKey, + onSaved +}: { + project: ProjectContext + target: RuntimeClientTarget + repoId: string + draftKey: string + onSaved: (project: ProjectContext) => void +}) { + const [base, setBase] = useState(project) + const draft = useAppStore((state) => state.projectHomeDrafts[draftKey]) + const goal = draft?.goal ?? base.coordination?.goal ?? '' + const instructions = draft?.instructions ?? base.coordination?.instructions ?? '' + function edit(nextGoal: string, nextInstructions: string) { + useAppStore.getState().setProjectHomeDraft(draftKey, { + goal: nextGoal, + instructions: nextInstructions, + expectedRevision: draft?.expectedRevision ?? base.coordination?.revision ?? 0 + }) + } + const [showSaved, setShowSaved] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + async function save() { + setBusy(true) + setError('') + setNotice('') + try { + const updated = await saveProject( + target, + base, + goal, + instructions, + draft?.expectedRevision ?? base.coordination?.revision ?? 0 + ) + setBase(updated) + onSaved(updated) + if (useAppStore.getState().projectHomeDrafts[draftKey] === draft) { + useAppStore.getState().setProjectHomeDraft(draftKey, null) + } + setNotice(translate('projectHome.saved', 'Saved.')) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(false) + } + } + async function reload() { + setBusy(true) + setError('') + setNotice('') + try { + const updated = await loadProject(target, repoId) + setBase(updated) + onSaved(updated) + if (draft && useAppStore.getState().projectHomeDrafts[draftKey] === draft) { + useAppStore.getState().setProjectHomeDraft(draftKey, { + goal, + instructions, + expectedRevision: updated.coordination?.revision ?? 0 + }) + } + setShowSaved(true) + // Preserve the user's draft; a fresh revision allows an explicit, reviewed retry. + setNotice( + draft + ? translate( + 'projectHome.reloaded', + 'Latest saved context loaded below. Your draft is preserved; compare it before saving again.' + ) + : translate('projectHome.loadedLatest', 'Loaded latest saved context.') + ) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(false) + } + } + return ( +
+

+ {translate('projectHome.context', 'Project context')} +

+ +