Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/bun/approvals-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/bun/db-openable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function makeTask() {
runId: null,
hasOpenableRun: false,
pendingInteractionCount: 0,
archivedAt: null,
createdAt: now,
updatedAt: now,
});
Expand Down
14 changes: 8 additions & 6 deletions src/bun/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>): Task | null {
const current = this.get(id);
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/bun/interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async function makeTaskWithCwd(id: string): Promise<string> {
updatedAt: Date.now(),
hasOpenableRun: false,
pendingInteractionCount: 0,
archivedAt: null,
});
return cwd;
}
Expand Down
1 change: 1 addition & 0 deletions src/bun/migrations/019_archived_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE tasks ADD COLUMN archived_at INTEGER;
2 changes: 2 additions & 0 deletions src/bun/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 },
];
62 changes: 62 additions & 0 deletions src/bun/orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
});
47 changes: 47 additions & 0 deletions src/bun/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/bun/reconcile-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function baseTask(overrides: Partial<Task> = {}): Task {
runId: null,
hasOpenableRun: false,
pendingInteractionCount: 0,
archivedAt: null,
createdAt: 0,
updatedAt: 0,
...overrides,
Expand Down
1 change: 1 addition & 0 deletions src/bun/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
31 changes: 30 additions & 1 deletion src/bun/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) })),
},
Expand Down
1 change: 1 addition & 0 deletions src/bun/task-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function makeTaskRow(taskId: string, agent: Task["agent"] = "claude-code"): Task
updatedAt: Date.now(),
hasOpenableRun: false,
pendingInteractionCount: 0,
archivedAt: null,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/bun/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function fakeTask(overrides: Partial<Task> & { workdir: string }): Task {
runId: null,
hasOpenableRun: false,
pendingInteractionCount: 0,
archivedAt: null,
createdAt: 0,
updatedAt: 0,
...overrides,
Expand Down
Loading