diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx index 9b63bce4..7bd8bf34 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, type ReactNode, type MouseEvent } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle, Inbox, X } from 'lucide-react'; +import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle, Inbox, Square, Trash2 } from 'lucide-react'; import { WorkspaceLayout } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { useClient } from 'hooks/useClient'; @@ -15,11 +15,14 @@ type JobSummary = { taskName: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; exitCode: number | null; + target: string | null; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } | null; createdAt: string; error: string | null; }; +const basename = (p: string | null) => (p ? p.replace(/\/+$/, '').split('/').pop() || p : null); + const formatDate = (iso: string) => new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); @@ -48,39 +51,45 @@ const useJobsData = () => { const timer = setInterval(load, 2500); return () => clearInterval(timer); }, [load]); - const cancel = useCallback( - (ev: MouseEvent, id: string) => { ev.stopPropagation(); client.post(`/jobs/${id}/stop`, {}).then(() => load()).catch(() => {}); }, + // Running → stop; queued/finished → delete the row. Then refresh. + const act = useCallback( + (ev: MouseEvent, job: JobSummary) => { + ev.stopPropagation(); + const req = job.status === 'running' ? client.post(`/jobs/${job.id}/stop`, {}) : client.delete(`/jobs/${job.id}`); + req.then(() => load()).catch(() => {}); + }, [client, load], ); - return { jobs, isLoading, cancel }; + const clearHistory = useCallback(() => { client.delete('/jobs/history').then(() => load()).catch(() => {}); }, [client, load]); + return { jobs, isLoading, act, clearHistory }; }; -type JobRowProps = { job: JobSummary; onCancel: (ev: MouseEvent, id: string) => void }; -const JobRow = ({ job, onCancel }: JobRowProps) => { +type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void }; +const JobRow = ({ job, onAction }: JobRowProps) => { const navigate = useNavigate(); const { id: activeId } = useParams<{ id: string }>(); - const cancellable = job.status === 'running' || job.status === 'pending'; + const isRunning = job.status === 'running'; + const target = basename(job.target); return (
- {cancellable && ( - - )} +
); }; @@ -91,7 +100,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => ( // Top-left panel: running first, then the FIFO queue (oldest pending on top — next to run). const ActiveJobsPanel = () => { - const { jobs, isLoading, cancel } = useJobsData(); + const { jobs, isLoading, act } = useJobsData(); const active = [ ...jobs.filter((j) => j.status === 'running'), ...jobs.filter((j) => j.status === 'pending').sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt)), @@ -109,7 +118,7 @@ const ActiveJobsPanel = () => { ) : active.length === 0 ? (
Nothing running
) : ( - active.map((job) => ) + active.map((job) => ) )} @@ -119,7 +128,7 @@ const ActiveJobsPanel = () => { // Bottom-left panel: finished / failed / stopped, newest first, searchable. const HistoryJobsPanel = () => { - const { jobs, isLoading, cancel } = useJobsData(); + const { jobs, isLoading, act, clearHistory } = useJobsData(); const [search, setSearch] = useState(''); const history = jobs .filter((j) => j.status !== 'running' && j.status !== 'pending') @@ -143,6 +152,11 @@ const HistoryJobsPanel = () => { className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40" /> + {history.length > 0 && ( + + )}
{isLoading ? ( @@ -150,7 +164,7 @@ const HistoryJobsPanel = () => { ) : history.length === 0 ? (
{search ? 'No matches' : 'No finished jobs'}
) : ( - history.map((job) => ) + history.map((job) => ) )}
diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index aa8c4e21..b17802a9 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -79,6 +79,8 @@ export { getPipelineJobsForUser, getOldestPendingJob, countPendingJobs, + deletePipelineJob, + deleteTerminalJobsForUser, markInterruptedJobs, } from './queries/pipeline-jobs'; diff --git a/src/databases/officer_db/src/queries/pipeline-jobs.ts b/src/databases/officer_db/src/queries/pipeline-jobs.ts index 8155e485..91a9a36b 100644 --- a/src/databases/officer_db/src/queries/pipeline-jobs.ts +++ b/src/databases/officer_db/src/queries/pipeline-jobs.ts @@ -1,4 +1,6 @@ -import { eq, and, asc, desc } from 'drizzle-orm'; +import { eq, and, inArray, asc, desc } from 'drizzle-orm'; + +const TERMINAL_STATUSES = ['completed', 'failed', 'stopped', 'interrupted'] as const; import { db } from '../db'; import { pipelineJobs } from '../schema/pipeline-jobs'; import type { PipelineJobInsert } from '../types'; @@ -26,6 +28,20 @@ export async function getPipelineJobsForUser(userId: number, limit = 50) { .limit(limit); } +// Delete a single job row. +export async function deletePipelineJob(id: string) { + await db.delete(pipelineJobs).where(eq(pipelineJobs.id, id)); +} + +// Delete all of a user's finished jobs (history); returns the deleted ids so their logs can be removed. +export async function deleteTerminalJobsForUser(userId: number) { + const rows = await db + .delete(pipelineJobs) + .where(and(eq(pipelineJobs.userId, userId), inArray(pipelineJobs.status, [...TERMINAL_STATUSES]))) + .returning({ id: pipelineJobs.id }); + return rows.map((r) => r.id); +} + // Count of the user's queued (pending) jobs — for the header badge. export async function countPendingJobs(userId: number) { const rows = await db diff --git a/src/servers/api/tasks/pipeline-job-manager.ts b/src/servers/api/tasks/pipeline-job-manager.ts index 4380ff65..90465a8a 100644 --- a/src/servers/api/tasks/pipeline-job-manager.ts +++ b/src/servers/api/tasks/pipeline-job-manager.ts @@ -8,13 +8,16 @@ import { getPipelineJobsForUser, getOldestPendingJob, countPendingJobs, + deletePipelineJob, + deleteTerminalJobsForUser, markInterruptedJobs, getUserById, } from 'officerdb'; +import { unlink } from 'node:fs/promises'; import { toShellUsername } from '../../data-path'; import { executePipeline } from './pipeline-executor'; import type { OutMessage } from './pipeline-executor'; -import { executeScript, type ScriptEvent } from './execute-script'; +import { executeScript, jobLogPath, type ScriptEvent } from './execute-script'; // Everything a job can stream — pipeline structural events plus script stdout/stderr/exit. type JobEvent = OutMessage | ScriptEvent; @@ -318,6 +321,23 @@ export async function getJobsForUser(userId: number) { })); } +// Delete a job (row + log). Refuses a running job — it must be stopped first. +export async function deleteJob(jobId: string): Promise<'deleted' | 'running' | 'noop'> { + if (liveJobs.has(jobId)) return 'running'; + const job = await getPipelineJob(jobId); + if (!job) return 'noop'; + await deletePipelineJob(jobId); + await unlink(jobLogPath(jobId)).catch(() => {}); + return 'deleted'; +} + +// Clear all finished jobs (rows + logs). Running/queued jobs are untouched. +export async function clearHistory(userId: number): Promise { + const ids = await deleteTerminalJobsForUser(userId); + await Promise.all(ids.map((id) => unlink(jobLogPath(id)).catch(() => {}))); + return ids.length; +} + // Lightweight header-badge summary: how many of the user's jobs are running / queued, and which one // is running (for the "running" badge's link). export async function getCounts(userId: number): Promise<{ running: number; runningJobId: string | null; queued: number }> { diff --git a/src/servers/api/tasks/pipeline-jobs-routes.ts b/src/servers/api/tasks/pipeline-jobs-routes.ts index 18973e2d..474524d8 100644 --- a/src/servers/api/tasks/pipeline-jobs-routes.ts +++ b/src/servers/api/tasks/pipeline-jobs-routes.ts @@ -24,6 +24,8 @@ pipelineJobsRouter.get('/', async (c) => { status: j.status, isLive: j.isLive, exitCode: j.exitCode, + // The file/folder the job is working on (for the list rows). + target: (j.inputs as Record | null)?.file_path ?? j.cwd ?? null, totalCost: j.totalCost, createdAt: j.createdAt, startedAt: j.startedAt, @@ -107,6 +109,23 @@ pipelineJobsRouter.post('/:id/stop', async (c) => { return c.json({ ok: true, result }); }); +// DELETE /history — clear all finished jobs (rows + logs). Before /:id so it isn't captured as an id. +pipelineJobsRouter.delete('/history', async (c) => { + const user = c.get('user'); + const cleared = await jobManager.clearHistory(user.id); + return c.json({ ok: true, cleared }); +}); + +// DELETE /:id — delete a job (queued or finished). Running jobs must be stopped first (409). +pipelineJobsRouter.delete('/:id', async (c) => { + const user = c.get('user'); + const job = await getPipelineJob(c.req.param('id')); + if (!job || job.userId !== user.id) return c.json({ error: 'Not found' }, 404); + const result = await jobManager.deleteJob(job.id); + if (result === 'running') return c.json({ error: 'Stop the job before deleting' }, 409); + return c.json({ ok: true, result }); +}); + // GET /:id — single job detail (full row). pipelineJobsRouter.get('/:id', async (c) => { const user = c.get('user');