diff --git a/.changeset/fn-6299-archive-any-column.md b/.changeset/fn-6299-archive-any-column.md new file mode 100644 index 0000000000..e9a304d512 --- /dev/null +++ b/.changeset/fn-6299-archive-any-column.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Allow tasks to be archived from any live board column and restored to their pre-archive column. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 80da35e9c7..0d55bcb3ac 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -577,6 +577,10 @@ fn task unarchive FN-001 fn task delete FN-001 --force ``` +Notes: +- `fn task archive` accepts any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) and preserves the original column for restore. +- `fn task unarchive` restores to the saved pre-archive column when available, with legacy archives falling back to `done`. + ### Branch conflict handling When executor branch allocation fails because `fusion/` is already checked out, Fusion marks the task failed/investigable and logs conflict details (existing worktree path, tip SHA, stranded commits). Operators should inspect and resolve conflicting local branches/worktrees with standard git tooling, then retry the task. diff --git a/docs/task-management.md b/docs/task-management.md index a2646ba2a9..0292f7423d 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -610,8 +610,9 @@ Behavior: ### Archive behavior -- `fn task archive ` moves done task to `archived` -- Dashboard delete confirmations for `done` tasks now include an **Archive Instead** action so users can preserve history without soft-deleting the task. This option is shown only for `done` tasks because the store-level archive contract only allows archiving from the `done` column. +- `fn task archive ` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected. +- Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`. +- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task. - Cleanup mode can persist compact metadata and remove the task directory - Archived tasks are read-only for task log/document writes: - `logEntry()` throws `Task is archived — logging is read-only` @@ -627,7 +628,7 @@ Behavior: Archive entries preserve key metadata needed for restoration, including: -- `id`, `title`, `description`, `priority`, `column` +- `id`, `title`, `description`, `priority`, `column`, `preArchiveColumn` - `dependencies`, `steps`, `currentStep` - `size`, `reviewLevel`, `prInfo` (primary mirror), `prInfos` (canonical linked PR list), `issueInfo` - `attachments` metadata @@ -643,7 +644,7 @@ Archive entries preserve key metadata needed for restoration, including: - Restores archive entry if directory is missing - Rebuilds `PROMPT.md` -- Moves task to `done` +- Moves task back to its recorded `preArchiveColumn` when available, falling back to the archived snapshot's prior `column`, then to `done` for legacy archive entries. - Logs “Task restored from archive” when recovering from compact archive entry ### Task-ID collision safety and operator recovery diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index a057ff3e75..34845be5d9 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -104,15 +104,15 @@ Request a refinement of a completed or in-review task. Creates a new follow-up t ### fn_task_archive -Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. +Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `id` | string | ✓ | Task ID to archive (e.g. FN-001). Must be in 'done' column. | +| `id` | string | ✓ | Task ID to archive from any live column (e.g. FN-001). | ### fn_task_unarchive -Unarchive an archived task (move from archived → done). Restores the task to the done column. +Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index f35f4cce5e..7430fc20b6 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -22,8 +22,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. | | `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. | | `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. | -| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. | -| `fn_task_unarchive` | Unarchive an archived task (move from archived → done). Restores the task to the done column. | +| `fn_task_archive` | Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. | +| `fn_task_unarchive` | Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. | | `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. | | `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. | | `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. | diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a3b285e933..a110b8dd35 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -305,7 +305,7 @@ Usage: fn task merge Merge an in-review task and close it fn task duplicate Duplicate a task (creates copy in triage) fn task refine [opts] Create a refinement task from done/in-review - fn task archive Archive a done task + fn task archive Archive a task (from any column) fn task unarchive Unarchive an archived task fn task delete [--force] [--allow-resurrection] Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation) diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 8d1039819c..1dc927202e 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1207,16 +1207,16 @@ export default function kbExtension(pi: ExtensionAPI) { name: "fn_task_archive", label: "fn: Archive Task", description: - "Archive a done task (move from done → archived). " + + "Archive a task from any live column (move to archived). " + "Archived tasks are preserved for historical reference but moved out of the main board view.", - promptSnippet: "Archive a done Fusion task (moves to archived column)", + promptSnippet: "Archive a Fusion task from any live column (moves to archived column)", promptGuidelines: [ - "Use to clean up old completed tasks from the done column", - "Only tasks in the 'done' column can be archived", + "Use to clean up tasks from any live board column when you want them hidden from active views", + "Already archived tasks cannot be archived again", "Archived tasks can be unarchived later if needed", ], parameters: Type.Object({ - id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }), + id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -1236,11 +1236,11 @@ export default function kbExtension(pi: ExtensionAPI) { name: "fn_task_unarchive", label: "fn: Unarchive Task", description: - "Unarchive an archived task (move from archived → done). " + - "Restores the task to the done column.", - promptSnippet: "Unarchive a Fusion task (restores to done column)", + "Unarchive an archived task (move from archived → its restore column). " + + "Restores to the pre-archive column when available, with active execution columns downgraded to todo.", + promptSnippet: "Unarchive a Fusion task (restores to its pre-archive column)", promptGuidelines: [ - "Use to restore an archived task back to the done column", + "Use to restore an archived task back to its pre-archive column when available", "Only tasks in the 'archived' column can be unarchived", ], parameters: Type.Object({ diff --git a/packages/core/src/__tests__/store-archive-search.test.ts b/packages/core/src/__tests__/store-archive-search.test.ts index 0ee83b39bb..4e58664133 100644 --- a/packages/core/src/__tests__/store-archive-search.test.ts +++ b/packages/core/src/__tests__/store-archive-search.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest"; import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { AgentStore } from "../agent-store.js"; @@ -24,16 +25,47 @@ describe("TaskStore Archive and Search", () => { afterAll(harness.afterAll); describe("archiveTask", () => { - it("archives a done task (moves done → archived)", async () => { - const task = await store.createTask({ description: "Test task" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.moveTask(task.id, "done"); + it("archives tasks from every live column and emits the real source column", async () => { + const liveColumns = ["triage", "todo", "in-progress", "in-review", "done"] as const; - const archived = await store.archiveTask(task.id); + for (const column of liveColumns) { + const task = await store.createTask({ description: `Archive from ${column}` }); + if (column === "todo") { + await store.moveTask(task.id, "todo"); + } else if (column === "in-progress") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + } else if (column === "in-review") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + } else if (column === "done") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + } + + const events: any[] = []; + store.on("task:moved", (data) => events.push(data)); + const archived = await store.archiveTask(task.id, false); + + expect(archived.column).toBe("archived"); + expect(archived.preArchiveColumn).toBe(column); + expect(events).toHaveLength(1); + expect(events[0].from).toBe(column); + expect(events[0].to).toBe("archived"); + } + }); + + it("archives a non-done task with cleanup enabled", async () => { + const task = await store.createTask({ description: "Cleanup archive from todo" }); + await store.moveTask(task.id, "todo"); + + const archived = await store.archiveTask(task.id, true); expect(archived.column).toBe("archived"); + expect(archived.preArchiveColumn).toBe("todo"); }); it("adds log entry 'Task archived'", async () => { @@ -78,11 +110,11 @@ describe("TaskStore Archive and Search", () => { expect(fetched.column).toBe("archived"); }); - it("throws error when task is not in 'done' column", async () => { + it("throws error when task is already archived", async () => { const task = await store.createTask({ description: "Test task" }); - // Task starts in triage, not done + await store.archiveTask(task.id, false); - await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'"); + await expect(store.archiveTask(task.id)).rejects.toThrow("already archived"); }); it("updates columnMovedAt timestamp", async () => { @@ -141,13 +173,27 @@ describe("TaskStore Archive and Search", () => { }); describe("unarchiveTask", () => { - it("unarchives an archived task (moves archived → done)", async () => { - const task = await store.createTask({ description: "Test task" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.moveTask(task.id, "done"); + it("unarchives to the pre-archive column, downgrading active execution columns to todo", async () => { + const todoTask = await store.createTask({ description: "Todo round trip" }); + await store.moveTask(todoTask.id, "todo"); + await store.archiveTask(todoTask.id, false); + await expect(store.unarchiveTask(todoTask.id)).resolves.toMatchObject({ column: "todo" }); + + const inProgressTask = await store.createTask({ description: "In progress round trip" }); + await store.moveTask(inProgressTask.id, "todo"); + await store.moveTask(inProgressTask.id, "in-progress"); + await store.archiveTask(inProgressTask.id, false); + await expect(store.unarchiveTask(inProgressTask.id)).resolves.toMatchObject({ column: "todo" }); + }); + + it("falls back to done for legacy archives without a pre-archive column", async () => { + const task = await store.createTask({ description: "Legacy archive" }); await store.archiveTask(task.id, false); + const dir = join(harness.rootDir(), ".fusion", "tasks", task.id); + const raw = await readFile(join(dir, "task.json"), "utf-8"); + const parsed = JSON.parse(raw); + delete parsed.preArchiveColumn; + await writeFile(join(dir, "task.json"), JSON.stringify(parsed)); const unarchived = await store.unarchiveTask(task.id); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 24c1a0e1f9..072bfa0882 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { MOVED_SETTINGS_KEYS, @@ -2109,6 +2109,7 @@ export class TaskStore extends EventEmitter { description: entry.description, priority: normalizeTaskPriority(entry.priority), column: "archived", + preArchiveColumn: entry.preArchiveColumn, dependencies: entry.dependencies ?? [], steps: entry.steps ?? [], currentStep: entry.currentStep ?? 0, @@ -2242,6 +2243,7 @@ export class TaskStore extends EventEmitter { description: task.description, priority: normalizeTaskPriority(task.priority), column: "archived", + preArchiveColumn: task.preArchiveColumn, dependencies: task.dependencies, steps: task.steps, currentStep: task.currentStep, @@ -10657,7 +10659,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } /** - * Archive a done task (move from done → archived). + * Archive a live task (move from any non-archived column → archived). * Logs the action and emits `task:moved` event. * @param optionsOrCleanup - Boolean cleanup flag for backward compatibility, * or an options object that also allows removeLineageReferences. @@ -10675,12 +10677,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task.log = []; } - if (task.column !== "done") { + if (task.column === "archived") { throw new Error( - `Cannot archive ${id}: task is in '${task.column}', must be in 'done'`, + `Cannot archive ${id}: task is already archived`, ); } + const fromColumn = task.column as Column; + task.preArchiveColumn = fromColumn; + const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false; const removeLineageReferences = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true; const lineageChildIds = this.findLiveLineageChildren(id); @@ -10708,11 +10713,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); await this.atomicWriteTaskJson(dir, task); + await this.writeTaskJsonFile(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); for (const lineageChild of rewrittenLineageChildren) { this.emit("task:updated", lineageChild); } - this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" }); + this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); return task; } @@ -10745,7 +10751,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} for (const lineageChild of rewrittenLineageChildren) { this.emit("task:updated", lineageChild); } - this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" }); + this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); return this.archiveEntryToTask(entry, false); }); } @@ -10758,8 +10764,28 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return this.archiveTask(id, true); } + private resolveUnarchiveTargetColumn(preArchiveColumn: unknown): Column { + if (!isColumn(preArchiveColumn) || preArchiveColumn === "archived") { + return "done"; + } + if (preArchiveColumn === "in-progress" || preArchiveColumn === "in-review") { + return "todo"; + } + return preArchiveColumn; + } + + private async readPreArchiveColumnFromTaskFile(dir: string): Promise { + try { + const raw = await readFile(join(dir, "task.json"), "utf-8"); + const parsed = JSON.parse(raw) as { preArchiveColumn?: unknown }; + return isColumn(parsed.preArchiveColumn) ? parsed.preArchiveColumn : undefined; + } catch { + return undefined; + } + } + /** - * Unarchive an archived task (move from archived → done). + * Unarchive an archived task (move from archived → its recorded source column). * If the active task row was cleaned up, restores from archive.db first. * Logs the action and emits `task:moved` event. */ @@ -10797,16 +10823,18 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // NOTE: No getTaskMergeBlocker check here — intentionally. // The merge blocker validates in-review → done transitions (ensuring code // has been properly reviewed before merging). An unarchived task was already - // merged in its previous lifecycle; this is just a restoration. The transient - // field clearing above ensures no stale blocker state leaks through. - task.column = "done"; + // archived in its previous lifecycle; this is just a restoration. The transient + // field clearing below ensures no stale blocker state leaks through. + const preArchiveColumn = task.preArchiveColumn ?? await this.readPreArchiveColumnFromTaskFile(dir); + const toColumn = this.resolveUnarchiveTargetColumn(preArchiveColumn); + task.column = toColumn; + task.preArchiveColumn = undefined; task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; - // Clear transient fields that should not persist into "done" column. - // Matches the clearing done by moveTask() for consistency — archived - // tasks may have been archived with stale state that should not reappear - // after unarchiving. + // Clear transient fields regardless of the restored column. Archived tasks + // may have been archived with stale execution state that should not reappear + // after unarchiving, especially when active columns are downgraded to todo. this.clearDoneTransientFields(task); task.log.push({ @@ -10820,7 +10848,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); - this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column, source: "engine" }); + this.emit("task:moved", { task, from: "archived" as Column, to: toColumn, source: "engine" }); return task; }); } @@ -13037,7 +13065,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} title: entry.title, description: entry.description, priority: normalizeTaskPriority(entry.priority), - column: "archived", // Will be changed to "done" by unarchiveTask + column: "archived", // Will be changed by unarchiveTask + preArchiveColumn: entry.preArchiveColumn, dependencies: entry.dependencies, steps: entry.steps, currentStep: entry.currentStep, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fe81423f69..b88366993b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2070,6 +2070,8 @@ export interface Task { /** The task's current column id. Widened to {@link ColumnId} so workflow-defined * custom columns are representable; flag-OFF paths only ever store legacy ids. */ column: ColumnId; + /** Source column captured when this task is archived; used to restore sensibly. */ + preArchiveColumn?: Column; dependencies: string[]; /** User-requested hint for triage: prefer splitting into child tasks when appropriate. */ breakIntoSubtasks?: boolean; @@ -4380,6 +4382,8 @@ export interface ArchivedTaskEntry { */ priority?: TaskPriority; column: "archived"; // Always archived when in the log + /** Source column captured at archive time; absent on legacy archive entries. */ + preArchiveColumn?: Column; dependencies: string[]; steps: TaskStep[]; currentStep: number; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 7655a88c63..8377770752 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts"], exclude: [ + "src/__tests__/db.test.ts", "src/__tests__/soft-delete-tasks.test.ts", "src/__tests__/store-get-task-columns.test.ts", "src/__tests__/task-dependency-mutation.test.ts", diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 65425b4312..c39cb5ed14 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -2043,7 +2043,7 @@ function TaskCardComponent({ )} - {task.column === "done" && onArchiveTask && ( + {task.column !== "archived" && onArchiveTask && (