Add features & update project
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, Github, Shield, User } from 'lucide-react';
|
||||
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -11,6 +11,7 @@ const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
|
||||
|
||||
const WorkerSettingsPage = () => (
|
||||
<section className='max-w-5xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Worker</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Monitor the agent worker and clean up old workspace state.
|
||||
</p>
|
||||
</div>
|
||||
<WorkerHealthPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default WorkerSettingsPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'agent/abort', { method: 'POST' }),
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'agent/status', { method: 'GET' }),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
proxyWorker,
|
||||
requireOwnedJob,
|
||||
routeJobId,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string; interactionId: string }> },
|
||||
) => {
|
||||
const params = await context.params;
|
||||
const jobId = await routeJobId({ params });
|
||||
const owned = await requireOwnedJob(jobId);
|
||||
if (!owned.ok) return owned.response;
|
||||
return await proxyWorker(
|
||||
jobId,
|
||||
`interactions/${encodeURIComponent(params.interactionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: await request.text(),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
proxyWorkerRoot,
|
||||
requireAuthenticatedUser,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async () => {
|
||||
const authenticated = await requireAuthenticatedUser();
|
||||
if (!authenticated.ok) return authenticated.response;
|
||||
return await proxyWorkerRoot('/cleanup', { method: 'POST' });
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
proxyWorkerRoot,
|
||||
requireAuthenticatedUser,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async () => {
|
||||
const authenticated = await requireAuthenticatedUser();
|
||||
if (!authenticated.ok) return authenticated.response;
|
||||
return await proxyWorkerRoot('/health', { method: 'GET' });
|
||||
};
|
||||
@@ -1,23 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Ban, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Textarea } from '@spoon/ui';
|
||||
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
export const AgentThread = ({
|
||||
jobId,
|
||||
messages,
|
||||
events,
|
||||
interactions,
|
||||
disabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
interactions: Doc<'agentInteractionRequests'>[];
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replying, setReplying] = useState<string>();
|
||||
|
||||
const send = async () => {
|
||||
if (!content.trim()) return;
|
||||
@@ -37,27 +42,141 @@ export const AgentThread = ({
|
||||
}
|
||||
};
|
||||
|
||||
const abort = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/abort`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Agent turn aborted.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not abort agent.');
|
||||
}
|
||||
};
|
||||
|
||||
const reply = async (
|
||||
interaction: Doc<'agentInteractionRequests'>,
|
||||
responseValue: string,
|
||||
) => {
|
||||
setReplying(interaction._id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/interactions/${interaction._id}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
externalRequestId: interaction.externalRequestId,
|
||||
response: responseValue,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Response sent.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not answer interaction.');
|
||||
} finally {
|
||||
setReplying(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages persist with this workspace.
|
||||
</p>
|
||||
<div className='border-border flex items-start justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages, tool activity, and requests persist with this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled}
|
||||
onClick={abort}
|
||||
>
|
||||
<Ban className='size-3' />
|
||||
Abort
|
||||
</Button>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{interactions.map((interaction) => (
|
||||
<article
|
||||
key={interaction._id}
|
||||
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium'>{interaction.title}</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{interaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
|
||||
{interaction.status === 'pending' ? (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'once')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
className={
|
||||
message.role === 'user'
|
||||
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
|
||||
: message.status === 'failed'
|
||||
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
|
||||
: 'border-border bg-background rounded-md border p-3 text-sm'
|
||||
}
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>{message.role}</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
<Badge
|
||||
variant={
|
||||
message.status === 'failed' ? 'destructive' : 'outline'
|
||||
}
|
||||
className='capitalize'
|
||||
>
|
||||
{message.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{message.content}</p>
|
||||
<p className='whitespace-pre-wrap'>
|
||||
{message.content ||
|
||||
(message.status === 'streaming' ? 'Working...' : '')}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{events.slice(-20).map((event) => (
|
||||
<article
|
||||
key={event._id}
|
||||
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>
|
||||
{event.phase} / {event.level}
|
||||
</span>
|
||||
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -13,17 +13,42 @@ import { AgentThread } from './agent-thread';
|
||||
import { CodeEditor } from './code-editor';
|
||||
import { CommandPanel } from './command-panel';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { FileTabs } from './file-tabs';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
|
||||
type OpenFileState = {
|
||||
path: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||
const interactions =
|
||||
useQuery(api.agentJobs.listInteractionRequests, {
|
||||
jobId,
|
||||
status: 'all',
|
||||
}) ?? [];
|
||||
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
||||
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string>();
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string>();
|
||||
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [hydratedUiState, setHydratedUiState] = useState(false);
|
||||
const [diff, setDiff] = useState('');
|
||||
|
||||
const workspaceDisabled =
|
||||
@@ -49,17 +74,59 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[path]: current[path] ?? {
|
||||
path,
|
||||
content: '',
|
||||
savedContent: '',
|
||||
loading: true,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as FileResponse;
|
||||
setSelectedPath(data.path);
|
||||
setFileContent(data.content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[data.path]: {
|
||||
path: data.path,
|
||||
content: data.content,
|
||||
savedContent: data.content,
|
||||
loading: false,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[jobId],
|
||||
);
|
||||
|
||||
const openFile = useCallback(
|
||||
(path: string) => {
|
||||
setOpenFilePaths((current) =>
|
||||
current.includes(path) ? current : [...current, path],
|
||||
);
|
||||
setActiveFilePath(path);
|
||||
if (!files[path]) {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[path];
|
||||
return next;
|
||||
});
|
||||
setOpenFilePaths((current) =>
|
||||
current.filter((filePath) => filePath !== path),
|
||||
);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}
|
||||
},
|
||||
[files, loadFile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -73,27 +140,143 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [job, loadDiff, loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiState || hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setOpenFilePaths(uiState.openFilePaths);
|
||||
setActiveFilePath(uiState.activeFilePath);
|
||||
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
|
||||
setVimEnabled(uiState.vimEnabled);
|
||||
setHydratedUiState(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [hydratedUiState, uiState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void patchUiState({
|
||||
jobId,
|
||||
openFilePaths,
|
||||
activeFilePath,
|
||||
vimEnabled,
|
||||
expandedDirectoryPaths,
|
||||
}).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 400);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [
|
||||
activeFilePath,
|
||||
expandedDirectoryPaths,
|
||||
hydratedUiState,
|
||||
jobId,
|
||||
openFilePaths,
|
||||
patchUiState,
|
||||
vimEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
for (const path of openFilePaths) {
|
||||
if (!files[path]) {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [files, hydratedUiState, loadFile, openFilePaths]);
|
||||
|
||||
if (job === undefined) {
|
||||
return (
|
||||
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
||||
);
|
||||
}
|
||||
|
||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!selectedPath) return;
|
||||
if (!activeFilePath) return;
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
}),
|
||||
content,
|
||||
saving: true,
|
||||
},
|
||||
}));
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path: selectedPath, content }),
|
||||
body: JSON.stringify({ path: activeFilePath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error('Could not save file.');
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
content,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
}),
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
setFileContent(content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
loading: false,
|
||||
}),
|
||||
content,
|
||||
savedContent: content,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
await loadDiff();
|
||||
toast.success('File saved.');
|
||||
};
|
||||
|
||||
const closeFile = (path: string) => {
|
||||
const file = files[path];
|
||||
if (file && file.content !== file.savedContent) {
|
||||
const confirmed = window.confirm(
|
||||
`Close ${path} and discard unsaved changes?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const index = openFilePaths.indexOf(path);
|
||||
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
|
||||
setOpenFilePaths(nextOpen);
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[path];
|
||||
return next;
|
||||
});
|
||||
if (activeFilePath === path) {
|
||||
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDirectory = (path: string) => {
|
||||
setExpandedDirectoryPaths((current) =>
|
||||
current.includes(path)
|
||||
? current.filter((directoryPath) => directoryPath !== path)
|
||||
: [...current, path],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</div>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}}
|
||||
selectedPath={activeFilePath}
|
||||
expandedPaths={expandedDirectoryPaths}
|
||||
onSelect={openFile}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background flex min-w-0 flex-col'>
|
||||
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
Thread
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
|
||||
<TabsContent
|
||||
value='editor'
|
||||
className='m-0 flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<FileTabs
|
||||
tabs={openFilePaths.map((path) => ({
|
||||
path,
|
||||
dirty: files[path]
|
||||
? files[path].content !== files[path].savedContent
|
||||
: false,
|
||||
}))}
|
||||
activePath={activeFilePath}
|
||||
onActivate={setActiveFilePath}
|
||||
onClose={closeFile}
|
||||
/>
|
||||
<CodeEditor
|
||||
path={selectedPath}
|
||||
content={fileContent}
|
||||
path={activeFilePath}
|
||||
content={activeFile?.content ?? ''}
|
||||
savedContent={activeFile?.savedContent ?? ''}
|
||||
readOnly={workspaceDisabled}
|
||||
vimEnabled={vimEnabled}
|
||||
onSave={saveFile}
|
||||
onVimEnabledChange={setVimEnabled}
|
||||
onChange={(content) => {
|
||||
if (!activeFilePath) return;
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
saving: false,
|
||||
}),
|
||||
content,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
@@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
import { languageForPath } from './languages';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -20,27 +22,27 @@ type VimMode = {
|
||||
export const CodeEditor = ({
|
||||
path,
|
||||
content,
|
||||
savedContent,
|
||||
readOnly,
|
||||
vimEnabled,
|
||||
onSave,
|
||||
onChange,
|
||||
onVimEnabledChange,
|
||||
}: {
|
||||
path?: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
readOnly: boolean;
|
||||
vimEnabled: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onChange: (content: string) => void;
|
||||
onVimEnabledChange: (enabled: boolean) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(content);
|
||||
setDirty(false);
|
||||
}, [content, path]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -71,13 +73,14 @@ export const CodeEditor = ({
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
setDirty(false);
|
||||
await onSave(content);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||
@@ -90,7 +93,7 @@ export const CodeEditor = ({
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='flex items-center gap-2 text-xs'>
|
||||
Vim
|
||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
|
||||
</label>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -107,7 +110,8 @@ export const CodeEditor = ({
|
||||
height='100%'
|
||||
width='100%'
|
||||
path={path}
|
||||
value={value}
|
||||
language={languageForPath(path)}
|
||||
value={content}
|
||||
theme='vs-dark'
|
||||
options={{
|
||||
readOnly,
|
||||
@@ -116,13 +120,20 @@ export const CodeEditor = ({
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
tabCompletion: 'on',
|
||||
wordBasedSuggestions: 'matchingDocuments',
|
||||
bracketPairColorization: { enabled: true },
|
||||
renderWhitespace: 'selection',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
}}
|
||||
onChange={(next) => {
|
||||
setValue(next ?? '');
|
||||
setDirty((next ?? '') !== content);
|
||||
const nextValue = next ?? '';
|
||||
onChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,42 +8,62 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const diffStats = (diff: string) => {
|
||||
const files = new Set<string>();
|
||||
let additions = 0;
|
||||
let removals = 0;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) files.add(line);
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
|
||||
}
|
||||
return { files: files.size, additions, removals };
|
||||
};
|
||||
|
||||
export const DiffViewer = ({
|
||||
diff,
|
||||
onRefresh,
|
||||
}: {
|
||||
diff: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) => (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||
}) => {
|
||||
const stats = diffStats(diff);
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground truncate text-xs'>
|
||||
{diff.trim()
|
||||
? `${stats.files} files, +${stats.additions} -${stats.removals}`
|
||||
: 'Current git diff'}
|
||||
</p>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
{diff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='100%'
|
||||
width='100%'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={diff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
No workspace diff yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{diff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='100%'
|
||||
width='100%'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={diff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
No workspace diff yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { Circle, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { basename } from './languages';
|
||||
|
||||
export type OpenFileTab = {
|
||||
path: string;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export const FileTabs = ({
|
||||
tabs,
|
||||
activePath,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: OpenFileTab[];
|
||||
activePath?: string;
|
||||
onActivate: (path: string) => void;
|
||||
onClose: (path: string) => void;
|
||||
}) => {
|
||||
if (tabs.length === 0) return null;
|
||||
return (
|
||||
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.path === activePath;
|
||||
return (
|
||||
<div
|
||||
key={tab.path}
|
||||
className={
|
||||
active
|
||||
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
|
||||
: 'border-border flex max-w-56 min-w-0 items-center border-r'
|
||||
}
|
||||
title={tab.path}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
|
||||
onClick={() => onActivate(tab.path)}
|
||||
>
|
||||
{tab.dirty ? (
|
||||
<Circle className='fill-primary text-primary size-2 flex-none' />
|
||||
) : null}
|
||||
<span className='truncate font-mono'>{basename(tab.path)}</span>
|
||||
</button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='mr-1 size-6 flex-none'
|
||||
aria-label={`Close ${tab.path}`}
|
||||
onClick={() => onClose(tab.path)}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
|
||||
const TreeNode = ({
|
||||
node,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggle,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath?: string;
|
||||
expandedPaths: Set<string>;
|
||||
onSelect: (path: string) => void;
|
||||
onToggle: (path: string) => void;
|
||||
depth?: number;
|
||||
}) => {
|
||||
if (node.type === 'directory') {
|
||||
const isRoot = !node.path;
|
||||
const expanded = isRoot || expandedPaths.has(node.path);
|
||||
return (
|
||||
<div>
|
||||
{node.path ? (
|
||||
<div
|
||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||
{!isRoot ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-expanded={expanded}
|
||||
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
onClick={() => onToggle(node.path)}
|
||||
>
|
||||
<ChevronRight className='size-3' />
|
||||
<Folder className='size-3' />
|
||||
{expanded ? (
|
||||
<ChevronDown className='size-3 flex-none' />
|
||||
) : (
|
||||
<ChevronRight className='size-3 flex-none' />
|
||||
)}
|
||||
{expanded ? (
|
||||
<FolderOpen className='size-3 flex-none' />
|
||||
) : (
|
||||
<Folder className='size-3 flex-none' />
|
||||
)}
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={`${child.type}:${child.path}`}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
depth={node.path ? depth + 1 : depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={`${child.type}:${child.path}`}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={node.path ? depth + 1 : depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,11 +89,15 @@ const TreeNode = ({
|
||||
export const FileTree = ({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleDirectory,
|
||||
}: {
|
||||
tree: FileTreeNode | null;
|
||||
selectedPath?: string;
|
||||
expandedPaths: string[];
|
||||
onSelect: (path: string) => void;
|
||||
onToggleDirectory: (path: string) => void;
|
||||
}) => {
|
||||
if (!tree) {
|
||||
return (
|
||||
@@ -76,8 +107,14 @@ export const FileTree = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='overflow-auto py-2'>
|
||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||
<div className='h-full overflow-auto py-2'>
|
||||
<TreeNode
|
||||
node={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={new Set(expandedPaths)}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggleDirectory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const languageForPath = (path?: string) => {
|
||||
if (!path) return undefined;
|
||||
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
|
||||
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
|
||||
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
|
||||
if (
|
||||
name.endsWith('.jsx') ||
|
||||
name.endsWith('.js') ||
|
||||
name.endsWith('.mjs') ||
|
||||
name.endsWith('.cjs')
|
||||
) {
|
||||
return 'javascript';
|
||||
}
|
||||
if (name.endsWith('.json')) return 'json';
|
||||
if (name.endsWith('.css')) return 'css';
|
||||
if (name.endsWith('.scss')) return 'scss';
|
||||
if (name.endsWith('.html')) return 'html';
|
||||
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
|
||||
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
|
||||
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
|
||||
if (name.endsWith('.py')) return 'python';
|
||||
if (name.endsWith('.rs')) return 'rust';
|
||||
if (name.endsWith('.go')) return 'go';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const basename = (path: string) => path.split('/').at(-1) ?? path;
|
||||
@@ -1,9 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from 'convex/react';
|
||||
import {
|
||||
ExternalLink,
|
||||
GitPullRequestDraft,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const WorkspaceActions = ({
|
||||
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
|
||||
job: Doc<'agentJobs'>;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const canDelete =
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const openPr = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||
@@ -26,6 +40,24 @@ export const WorkspaceActions = ({
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspace({ jobId: job._id });
|
||||
toast.success('Workspace deleted.');
|
||||
router.push(`/spoons/${job.spoonId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete workspace.');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
|
||||
<Square className='size-4' />
|
||||
Stop
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button type='button' variant='destructive' size='sm' onClick={remove}>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
|
||||
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -22,10 +22,17 @@ const formatTime = (value: number) =>
|
||||
|
||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
||||
jobs[0]?._id ?? null,
|
||||
);
|
||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
||||
const selectedJobCanDelete = selectedJob
|
||||
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(
|
||||
selectedJob.workspaceStatus ?? '',
|
||||
)
|
||||
: false;
|
||||
|
||||
if (!jobs.length) {
|
||||
return (
|
||||
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
{selectedJobCanDelete ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspace({ jobId: selectedJob._id });
|
||||
toast.success('Workspace deleted.');
|
||||
setSelectedJobId(null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete workspace.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { RefreshCw, Trash2, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type WorkerHealth = {
|
||||
ok: boolean;
|
||||
workerId: string;
|
||||
convexUrl: string;
|
||||
runtime: string;
|
||||
containerRuntime: string;
|
||||
containerAccess: string;
|
||||
jobImage: string;
|
||||
workdir: string;
|
||||
network?: string;
|
||||
httpPort: number;
|
||||
activeWorkspaceCount: number;
|
||||
workspaceContainers: string[];
|
||||
};
|
||||
|
||||
type CleanupResult = {
|
||||
removedContainers: string[];
|
||||
removedWorkdirs: string[];
|
||||
};
|
||||
|
||||
export const WorkerHealthPanel = () => {
|
||||
const [health, setHealth] = useState<WorkerHealth | null>(null);
|
||||
const [healthError, setHealthError] = useState<string>();
|
||||
const [loadingHealth, setLoadingHealth] = useState(false);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [olderThanDays, setOlderThanDays] = useState(7);
|
||||
const deletableCount =
|
||||
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
|
||||
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
|
||||
|
||||
const refreshHealth = async () => {
|
||||
setLoadingHealth(true);
|
||||
setHealthError(undefined);
|
||||
try {
|
||||
const response = await fetch('/api/agent-worker/health');
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setHealth((await response.json()) as WorkerHealth);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setHealthError(message);
|
||||
setHealth(null);
|
||||
} finally {
|
||||
setLoadingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHealth();
|
||||
}, []);
|
||||
|
||||
const cleanupOrphans = async () => {
|
||||
setCleaning(true);
|
||||
try {
|
||||
const response = await fetch('/api/agent-worker/cleanup', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const result = (await response.json()) as CleanupResult;
|
||||
toast.success(
|
||||
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
|
||||
);
|
||||
await refreshHealth();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not clean worker resources.');
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOld = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
const result = await deleteOldWorkspaces({
|
||||
olderThanDays,
|
||||
limit: 100,
|
||||
});
|
||||
toast.success(`Deleted ${result.deleted} workspaces.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete old workspaces.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle>Worker health</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Runtime status for the server-side agent worker.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loadingHealth}
|
||||
onClick={() => void refreshHealth()}
|
||||
>
|
||||
<RefreshCw className='size-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{healthError ? (
|
||||
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-3 text-sm'>
|
||||
{healthError}
|
||||
</div>
|
||||
) : null}
|
||||
{health ? (
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant={health.ok ? 'secondary' : 'destructive'}>
|
||||
{health.ok ? 'healthy' : 'unhealthy'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{health.workerId}</Badge>
|
||||
<Badge variant='outline'>
|
||||
{health.containerRuntime} / {health.containerAccess}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Convex</dt>
|
||||
<dd className='font-mono break-all'>{health.convexUrl}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Job image</dt>
|
||||
<dd className='font-mono break-all'>{health.jobImage}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Workdir</dt>
|
||||
<dd className='font-mono break-all'>{health.workdir}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Network</dt>
|
||||
<dd className='font-mono break-all'>
|
||||
{health.network ?? 'none'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>HTTP port</dt>
|
||||
<dd>{health.httpPort}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Active workspaces</dt>
|
||||
<dd>{health.activeWorkspaceCount}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Workspace containers
|
||||
</p>
|
||||
<p className='mt-1 font-mono text-sm'>
|
||||
{health.workspaceContainers.length
|
||||
? health.workspaceContainers.join(', ')
|
||||
: 'none'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : !healthError ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{loadingHealth ? 'Checking worker...' : 'No worker response yet.'}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Cleanup</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Remove stopped workspace records and orphaned local worker
|
||||
resources.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-[12rem_1fr_auto] md:items-end'>
|
||||
<label className='space-y-1'>
|
||||
<span className='text-sm font-medium'>Older than days</span>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={olderThanDays}
|
||||
onChange={(event) =>
|
||||
setOlderThanDays(
|
||||
Math.max(Number.parseInt(event.target.value, 10) || 0, 0),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{deletableCount} stopped, cancelled, failed, timed out, or expired
|
||||
workspaces match this age filter.
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
disabled={deleting || deletableCount === 0}
|
||||
onClick={() => void deleteOld()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete old
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Remove inactive Spoon job containers and inactive directories
|
||||
under the configured worker workdir.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={cleaning}
|
||||
onClick={() => void cleanupOrphans()}
|
||||
>
|
||||
<Wrench className='size-4' />
|
||||
Clean orphans
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,6 +32,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
||||
return { ok: true as const };
|
||||
};
|
||||
|
||||
export const requireAuthenticatedUser = async () => {
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
await fetchQuery(api.auth.getUser, {}, { token });
|
||||
return { ok: true as const };
|
||||
};
|
||||
|
||||
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
|
||||
const token = workerToken();
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'content-type':
|
||||
response.headers.get('content-type') ?? 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const proxyWorker = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
action: string,
|
||||
|
||||
Reference in New Issue
Block a user