diff --git a/src/bun/approvals-endpoint.test.ts b/src/bun/approvals-endpoint.test.ts index 3bd0288..fb153cf 100644 --- a/src/bun/approvals-endpoint.test.ts +++ b/src/bun/approvals-endpoint.test.ts @@ -55,6 +55,7 @@ async function seedPendingApproval(args: { updatedAt: Date.now(), hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, }); const { registerApproval } = await import("./interactions.ts"); const { id } = registerApproval({ @@ -187,6 +188,7 @@ async function seedTaskWithSavedRule(args: { updatedAt: Date.now(), hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, }); // Pre-write the allow-rule directly to the task's settings file so the // route's lookupAllowRule call hits "allow" — same shape we'd get if the diff --git a/src/bun/db-openable.test.ts b/src/bun/db-openable.test.ts index d3889e0..38db5f8 100644 --- a/src/bun/db-openable.test.ts +++ b/src/bun/db-openable.test.ts @@ -44,6 +44,7 @@ function makeTask() { runId: null, hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, createdAt: now, updatedAt: now, }); diff --git a/src/bun/db.ts b/src/bun/db.ts index 32e9b0e..b5a4666 100644 --- a/src/bun/db.ts +++ b/src/bun/db.ts @@ -58,6 +58,7 @@ type TaskRow = { mode: string | null; model: string | null; effort: string | null; refs: string; run_id: string | null; created_at: number; updated_at: number; + archived_at: number | null; /** SQLite EXISTS returns 0/1; we map to boolean in toTask. Computed via * a correlated subquery in `list` / `get` — see those for the full SQL. */ has_openable_run?: number; @@ -108,6 +109,7 @@ const toTask = (r: TaskRow): Task => ({ pendingInteractionCount: countPendingForTask(r.id), createdAt: r.created_at, updatedAt: r.updated_at, + archivedAt: r.archived_at, }); // LEFT JOIN + aggregation, so the runs scan happens once instead of once @@ -141,19 +143,19 @@ export const tasks = { `INSERT INTO tasks (id, title, prompt, "column", agent, workdir, isolation, branch, worktree_path, base_ref, mode, model, effort, refs, - run_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + run_id, created_at, updated_at, archived_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ t.id, t.title, t.prompt, t.column, t.agent, t.workdir, t.isolation, t.branch, t.worktreePath, t.baseRef, t.mode, t.model, t.effort, JSON.stringify(t.references ?? []), - t.runId, t.createdAt, t.updatedAt, + t.runId, t.createdAt, t.updatedAt, t.archivedAt ?? null, ], ); // Round-trip via `get` so the returned shape carries the computed // hasOpenableRun field (false for a brand-new task — but callers // that mutate t shouldn't accidentally get a stale shape). - return this.get(t.id) ?? { ...t, hasOpenableRun: false, pendingInteractionCount: 0 }; + return this.get(t.id) ?? { ...t, hasOpenableRun: false, pendingInteractionCount: 0, archivedAt: null }; }, update(id: string, patch: Partial): Task | null { const current = this.get(id); @@ -163,13 +165,13 @@ export const tasks = { `UPDATE tasks SET title=?, prompt=?, "column"=?, agent=?, workdir=?, isolation=?, branch=?, worktree_path=?, base_ref=?, mode=?, model=?, effort=?, refs=?, - run_id=?, updated_at=? + run_id=?, updated_at=?, archived_at=? WHERE id=?`, [ next.title, next.prompt, next.column, next.agent, next.workdir, next.isolation, next.branch, next.worktreePath, next.baseRef, next.mode, next.model, next.effort, JSON.stringify(next.references ?? []), - next.runId, next.updatedAt, id, + next.runId, next.updatedAt, next.archivedAt ?? null, id, ], ); // Re-fetch so hasOpenableRun reflects the row state immediately after diff --git a/src/bun/interactions.test.ts b/src/bun/interactions.test.ts index 098cf49..f3ab6bd 100644 --- a/src/bun/interactions.test.ts +++ b/src/bun/interactions.test.ts @@ -55,6 +55,7 @@ async function makeTaskWithCwd(id: string): Promise { updatedAt: Date.now(), hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, }); return cwd; } diff --git a/src/bun/migrations/019_archived_at.sql b/src/bun/migrations/019_archived_at.sql new file mode 100644 index 0000000..ebaba6f --- /dev/null +++ b/src/bun/migrations/019_archived_at.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN archived_at INTEGER; diff --git a/src/bun/migrations/index.ts b/src/bun/migrations/index.ts index c94e323..9c09448 100644 --- a/src/bun/migrations/index.ts +++ b/src/bun/migrations/index.ts @@ -18,6 +18,7 @@ import m015 from "./015_default_model_effort.sql" with { type: "text" }; import m016 from "./016_disable_codex.sql" with { type: "text" }; import m017 from "./017_drop_approval_rules.sql" with { type: "text" }; import m018 from "./018_run_events_dedup.sql" with { type: "text" }; +import m019 from "./019_archived_at.sql" with { type: "text" }; import type { Migration } from "../migrate.ts"; @@ -40,4 +41,5 @@ export const migrations: Migration[] = [ { id: "016_disable_codex", sql: m016 }, { id: "017_drop_approval_rules", sql: m017 }, { id: "018_run_events_dedup", sql: m018 }, + { id: "019_archived_at", sql: m019 }, ]; diff --git a/src/bun/orchestrator.test.ts b/src/bun/orchestrator.test.ts index 3c8b0db..08f32af 100644 --- a/src/bun/orchestrator.test.ts +++ b/src/bun/orchestrator.test.ts @@ -75,3 +75,65 @@ test("createTask + startTask runs to completion and emits stdout", async () => { expect(out.join("")).toContain("hello world"); expect(statuses.some((s) => s.startsWith("exit:"))).toBe(true); }); + +test("archiveTask refuses tasks that aren't in the Done column", async () => { + const { createTask, archiveTask } = await import("./orchestrator.ts"); + const { db } = await import("./db.ts"); + + const created = await createTask({ + title: "not done yet", + prompt: "p", + agent: "claude-code", + workdir: process.cwd(), + isolation: "none", + }); + if ("error" in created) throw new Error(created.error); + try { + const res = archiveTask(created.task.id); + expect("error" in res).toBe(true); + if ("error" in res) expect(res.error).toMatch(/done/i); + } finally { + db.run(`DELETE FROM tasks WHERE id = ?`, [created.task.id]); + } +}); + +test("archiveTask stamps archivedAt and unarchiveTask clears it", async () => { + const { createTask, archiveTask, unarchiveTask } = await import("./orchestrator.ts"); + const { db, tasks } = await import("./db.ts"); + + const created = await createTask({ + title: "wind-down", + prompt: "p", + agent: "claude-code", + workdir: process.cwd(), + isolation: "none", + }); + if ("error" in created) throw new Error(created.error); + // Move to Done so archive is allowed. updateColumn is internal, but the + // PATCH allow-list lets `column` through — use tasks.update directly here + // to keep the test focused on archive semantics. + tasks.update(created.task.id, { column: "done" }); + try { + const before = tasks.get(created.task.id); + expect(before?.archivedAt).toBeNull(); + + const archived = archiveTask(created.task.id); + expect("task" in archived).toBe(true); + if ("task" in archived) { + expect(archived.task.archivedAt).not.toBeNull(); + expect(typeof archived.task.archivedAt).toBe("number"); + } + + // Idempotent: archiving twice is a no-op success. + const archivedAgain = archiveTask(created.task.id); + expect("task" in archivedAgain).toBe(true); + + const restored = unarchiveTask(created.task.id); + expect("task" in restored).toBe(true); + if ("task" in restored) { + expect(restored.task.archivedAt).toBeNull(); + } + } finally { + db.run(`DELETE FROM tasks WHERE id = ?`, [created.task.id]); + } +}); diff --git a/src/bun/orchestrator.ts b/src/bun/orchestrator.ts index f09c63d..2d1b7fc 100644 --- a/src/bun/orchestrator.ts +++ b/src/bun/orchestrator.ts @@ -983,10 +983,57 @@ export async function createTask( pendingInteractionCount: 0, createdAt: now, updatedAt: now, + archivedAt: null, }); return { task }; } +/** + * Archive a finished task: stamp `archivedAt`, kill its claude tmux session + * (best-effort) so a background REPL doesn't outlive the user's interest in + * the task. Worktree, run history, and prompt stay intact for later reference. + * + * Only allowed when the task is in the `done` column — archive is the + * terminal step of the explicit review → done → archive flow. + */ +export function archiveTask(taskId: string): { task: Task } | { error: string } { + const task = tasks.get(taskId); + if (!task) return { error: "task not found" }; + if (task.column !== "done") { + return { error: "only tasks in Done can be archived" }; + } + // Defence-in-depth: column='done' should imply no live run, but column is + // freely PATCHable (drag-to-Done on a running card is allowed today). If a + // run is still active, refuse rather than killing tmux out from under it — + // the exit handler would then flip the now-archived task to 'ready' and + // leave the row in a contradictory state. + if (task.runId && active.has(task.runId)) { + return { error: "task is still running — cancel the run before archiving" }; + } + if (task.archivedAt != null) { + return { task }; + } + const updated = tasks.update(taskId, { archivedAt: Date.now() }); + if (!updated) return { error: "task not found" }; + // Same contract as deleteTask: dropSession is non-throwing (it best-efforts + // tmux teardown internally). Don't wrap — a silent catch would hide a + // regression in claude-tmux from the next reviewer. + if (resolveHarness(task.agent)?.kind === "claude-code") dropSession(taskId); + return { task: updated }; +} + +/** Reverse of `archiveTask`: clear the timestamp. No tmux work — sending a + * follow-up message on a non-archived task already spawns a fresh session via + * the resume path. */ +export function unarchiveTask(taskId: string): { task: Task } | { error: string } { + const task = tasks.get(taskId); + if (!task) return { error: "task not found" }; + if (task.archivedAt == null) return { task }; + const updated = tasks.update(taskId, { archivedAt: null }); + if (!updated) return { error: "task not found" }; + return { task: updated }; +} + /** * Delete a task and best-effort tear down its worktree. Kills any active run * first so we don't leave a stale process around. diff --git a/src/bun/reconcile-session.test.ts b/src/bun/reconcile-session.test.ts index 1160232..001943c 100644 --- a/src/bun/reconcile-session.test.ts +++ b/src/bun/reconcile-session.test.ts @@ -32,6 +32,7 @@ function baseTask(overrides: Partial = {}): Task { runId: null, hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/src/bun/reconcile.test.ts b/src/bun/reconcile.test.ts index 36ce476..273be1d 100644 --- a/src/bun/reconcile.test.ts +++ b/src/bun/reconcile.test.ts @@ -39,6 +39,7 @@ test("reconcileOrphans marks running rows as orphaned and returns tasks to ready runId, hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, createdAt: now, updatedAt: now, }); diff --git a/src/bun/server.ts b/src/bun/server.ts index 1b1561e..57c2a70 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -14,7 +14,7 @@ import { HarnessInUseError, dataDir, } from "./db.ts"; -import { createTask, deleteTask, startTask, cancelRun, reconcileTaskSession, sendInput, subscribe, subscribeGlobal } from "./orchestrator.ts"; +import { archiveTask, createTask, deleteTask, startTask, cancelRun, reconcileTaskSession, sendInput, subscribe, subscribeGlobal, unarchiveTask } from "./orchestrator.ts"; import { checkAllHarnesses } from "./agent-status.ts"; import { applyUpdate, checkForUpdate, getUpdateSnapshot } from "./updater.ts"; import { @@ -700,6 +700,17 @@ export function startApiServer() { if (!before) { return json({ error: "not found" }, { status: 404, headers: corsHeaders(req) }); } + // Archived rows are frozen — the UI hides every mutator (drag is + // disabled, action buttons are stripped, the composer is replaced + // by a footer). Enforce it server-side too so a direct API caller + // (or a stale tab racing the timestamp flip) can't drag the row + // back to a live column and re-trigger session reconciliation. + if (before.archivedAt != null) { + return json( + { error: "task is archived — unarchive it before editing" }, + { status: 400, headers: corsHeaders(req) }, + ); + } const patch = filterPatch(await req.json()); // Prevent workdir from being swapped after a worktree has been // materialized. The worktree is registered against the original repo; @@ -786,6 +797,24 @@ export function startApiServer() { }), }, + "/tasks/:id/archive": { + POST: authed((req) => { + const result = archiveTask(req.params.id); + return "error" in result + ? json(result, { status: 400, headers: corsHeaders(req) }) + : json(result.task, { headers: corsHeaders(req) }); + }), + }, + + "/tasks/:id/unarchive": { + POST: authed((req) => { + const result = unarchiveTask(req.params.id); + return "error" in result + ? json(result, { status: 400, headers: corsHeaders(req) }) + : json(result.task, { headers: corsHeaders(req) }); + }), + }, + "/tasks/:id/runs": { GET: authed((req) => json(runs.listForTask(req.params.id), { headers: corsHeaders(req) })), }, diff --git a/src/bun/task-events.test.ts b/src/bun/task-events.test.ts index f1a94fe..ade6b0a 100644 --- a/src/bun/task-events.test.ts +++ b/src/bun/task-events.test.ts @@ -60,6 +60,7 @@ function makeTaskRow(taskId: string, agent: Task["agent"] = "claude-code"): Task updatedAt: Date.now(), hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, }; } diff --git a/src/bun/worktree.test.ts b/src/bun/worktree.test.ts index de982a8..8fb0b44 100644 --- a/src/bun/worktree.test.ts +++ b/src/bun/worktree.test.ts @@ -44,6 +44,7 @@ function fakeTask(overrides: Partial & { workdir: string }): Task { runId: null, hasOpenableRun: false, pendingInteractionCount: 0, + archivedAt: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 7a76367..fd39293 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -77,6 +77,7 @@ export default function App() { const [textQuery, setTextQuery] = useState(""); const [repoFilter, setRepoFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); + const [archivedView, setArchivedView] = useState<"active" | "all" | "archived">("active"); const [harnessFilter, setHarnessFilter] = useState([]); const [settingsOpen, setSettingsOpen] = useState(false); const [tmuxDialogOpen, setTmuxDialogOpen] = useState(false); @@ -311,9 +312,11 @@ export default function App() { } if (repoFilter.length > 0 && !repoFilter.includes(t.workdir)) return false; if (harnessFilter.length > 0 && !harnessFilter.includes(t.agent)) return false; + if (archivedView === "active" && t.archivedAt != null) return false; + if (archivedView === "archived" && t.archivedAt == null) return false; return true; }); - }, [tasks, textQuery, repoFilter, harnessFilter]); + }, [tasks, textQuery, repoFilter, harnessFilter, archivedView]); const visibleColumns = useMemo( () => (statusFilter.length === 0 ? COLUMNS : COLUMNS.filter((c) => statusFilter.includes(c.id))), @@ -373,6 +376,40 @@ export default function App() { surfaceError(e); } }; + const markDone = async (t: Task) => { + setTasks((cur) => cur.map((x) => (x.id === t.id ? { ...x, column: "done" } : x))); + try { + setError(null); + await api.moveTask(t.id, "done"); + await refresh(); + } catch (e) { + surfaceError(e); + await refresh(); + } + }; + const archive = async (t: Task) => { + const now = Date.now(); + setTasks((cur) => cur.map((x) => (x.id === t.id ? { ...x, archivedAt: now } : x))); + try { + setError(null); + await api.archiveTask(t.id); + await refresh(); + } catch (e) { + surfaceError(e); + await refresh(); + } + }; + const unarchive = async (t: Task) => { + setTasks((cur) => cur.map((x) => (x.id === t.id ? { ...x, archivedAt: null } : x))); + try { + setError(null); + await api.unarchiveTask(t.id); + await refresh(); + } catch (e) { + surfaceError(e); + await refresh(); + } + }; const del = async (t: Task) => { const ok = await confirm({ title: `Delete "${t.title}"?`, @@ -513,6 +550,8 @@ export default function App() { onRepoFilterChange={setRepoFilter} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} + archivedView={archivedView} + onArchivedViewChange={setArchivedView} harnessFilter={harnessFilter} onHarnessFilterChange={setHarnessFilter} projects={projects} @@ -538,6 +577,9 @@ export default function App() { onCancel={cancel} onDelete={del} onOpen={setSelected} + onMarkDone={markDone} + onArchive={archive} + onUnarchive={unarchive} /> ))} @@ -552,6 +594,8 @@ export default function App() { agentModels={agentModels} homeDir={homeDir} onClose={() => setSelected(null)} + onArchive={archive} + onUnarchive={unarchive} /> void; onDelete: (t: Task) => void; onOpen: (t: Task) => void; + onMarkDone: (t: Task) => void; + onArchive: (t: Task) => void; + onUnarchive: (t: Task) => void; } -export function Column({ id, label, tasks, homeDir, onStart, onCancel, onDelete, onOpen }: Props) { +export function Column({ id, label, tasks, homeDir, onStart, onCancel, onDelete, onOpen, onMarkDone, onArchive, onUnarchive }: Props) { const { setNodeRef, isOver } = useDroppable({ id }); return (
))}
diff --git a/src/mainview/components/kanban/KanbanFilters.tsx b/src/mainview/components/kanban/KanbanFilters.tsx index 46f6963..01e8c51 100644 --- a/src/mainview/components/kanban/KanbanFilters.tsx +++ b/src/mainview/components/kanban/KanbanFilters.tsx @@ -3,8 +3,11 @@ import { AgentIcon } from "@/components/kanban/AgentIcon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { MultiSearchSelect } from "@/components/ui/multi-search-select"; +import { Select } from "@/components/ui/select"; import { COLUMNS, type ColumnId, type Harness, type Project } from "../../../shared/types.ts"; +export type ArchivedView = "active" | "all" | "archived"; + interface Props { textQuery: string; onTextQueryChange: (v: string) => void; @@ -12,6 +15,8 @@ interface Props { onRepoFilterChange: (v: string[]) => void; statusFilter: ColumnId[]; onStatusFilterChange: (v: ColumnId[]) => void; + archivedView: ArchivedView; + onArchivedViewChange: (v: ArchivedView) => void; harnessFilter: string[]; onHarnessFilterChange: (v: string[]) => void; projects: Project[]; @@ -36,6 +41,8 @@ export function KanbanFilters({ onRepoFilterChange, statusFilter, onStatusFilterChange, + archivedView, + onArchivedViewChange, harnessFilter, onHarnessFilterChange, projects, @@ -71,6 +78,7 @@ export function KanbanFilters({ textQuery !== "" || repoFilter.length > 0 || statusFilter.length > 0 + || archivedView !== "active" || harnessFilter.length > 0; return ( @@ -111,6 +119,16 @@ export function KanbanFilters({ leadingIcon={} className="w-48" /> + {anyActive && ( - {canControl && ( + {!archived && canControl && ( )} + {!archived && task.column === "done" && ( + + )} + {archived && ( + + )} @@ -820,102 +839,108 @@ function RunPanelBody({ one drop zone so dragging a screenshot anywhere over the input area (chips, textarea, send button gap) routes through the same capture path. */} -
- {canSend && ( - - )} - {canSend && latestRun?.status === "succeeded" && hasChanges && !sending && ( -
+ {archived ? ( +
+ This task is archived. Unarchive it to send messages. +
+ ) : ( +
+ {canSend && ( + + )} + {canSend && latestRun?.status === "succeeded" && hasChanges && !sending && ( +
+ +
+ )} +
+
+