FN-6299: allow archiving tasks from any column
Allow task archiving from every live board column while preserving a safe restore target. - Record the pre-archive column and restore archived tasks to that column, downgrading active execution columns to todo. - Expose archive actions and CLI/tooling documentation for all non-archived tasks. - Expand store, dashboard, and route tests for archive/unarchive behavior across columns. - Quarantine the flaky core db test observed during verification and add the published package changeset. Files changed: .changeset/fn-6299-archive-any-column.md | 5 ++ docs/cli-reference.md | 4 ++ docs/task-management.md | 9 +-- .../cli/skill/fusion/references/extension-tools.md | 6 +- .../skill/fusion/references/fusion-capabilities.md | 4 +- packages/cli/src/bin.ts | 2 +- packages/cli/src/extension.ts | 18 ++--- .../src/__tests__/store-archive-search.test.ts | 76 +++++++++++++++++----- packages/core/src/store.ts | 61 ++++++++++++----- packages/core/src/types.ts | 4 ++ packages/core/vitest.config.ts | 1 + packages/dashboard/app/components/TaskCard.tsx | 2 +- .../dashboard/app/components/TaskDetailModal.tsx | 2 +- .../app/components/__tests__/TaskCard.test.tsx | 35 +++++++++- ...etailModal.responsive-and-dependencies.test.tsx | 26 ++++++++ .../src/__tests__/routes-tasks-ops.test.ts | 8 +-- .../src/routes/register-task-workflow-routes.ts | 9 +-- scripts/lib/test-quarantine.json | 5 ++ 18 files changed, 215 insertions(+), 62 deletions(-) Fusion-Task-Id: FN-6299 Fusion-Task-Lineage: fd5b5c12-35b8-4843-ab4e-14608d1ea395
This commit is contained in:
5
.changeset/fn-6299-archive-any-column.md
Normal file
5
.changeset/fn-6299-archive-any-column.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Allow tasks to be archived from any live board column and restored to their pre-archive column.
|
||||
@@ -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/<task-id>` 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.
|
||||
|
||||
@@ -610,8 +610,9 @@ Behavior:
|
||||
|
||||
### Archive behavior
|
||||
|
||||
- `fn task archive <id>` 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 <id>` 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 <id> 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
|
||||
|
||||
@@ -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 |
|
||||
|-----------|------|----------|-------------|
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -305,7 +305,7 @@ Usage:
|
||||
fn task merge <id> Merge an in-review task and close it
|
||||
fn task duplicate <id> Duplicate a task (creates copy in triage)
|
||||
fn task refine <id> [opts] Create a refinement task from done/in-review
|
||||
fn task archive <id> Archive a done task
|
||||
fn task archive <id> Archive a task (from any column)
|
||||
fn task unarchive <id> Unarchive an archived task
|
||||
fn task delete <id> [--force] [--allow-resurrection]
|
||||
Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<TaskStoreEvents> {
|
||||
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<TaskStoreEvents> {
|
||||
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<Column | undefined> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2043,7 +2043,7 @@ function TaskCardComponent({
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{task.column === "done" && onArchiveTask && (
|
||||
{task.column !== "archived" && onArchiveTask && (
|
||||
<button
|
||||
className="card-archive-btn"
|
||||
onClick={handleArchiveClick}
|
||||
|
||||
@@ -1763,7 +1763,7 @@ export function TaskDetailContent({
|
||||
const handleDelete = useCallback(async () => {
|
||||
let allowResurrection = false;
|
||||
|
||||
if (task.column === "done" && onArchiveTask) {
|
||||
if (task.column !== "archived" && onArchiveTask) {
|
||||
const deleteChoice = await confirmWithChoice({
|
||||
title: t("taskDetail.delete.title", "Delete Task"),
|
||||
message: t("taskDetail.delete.message", "Delete {{id}}?", { id: task.id }),
|
||||
|
||||
@@ -462,6 +462,37 @@ describe("TaskCard", () => {
|
||||
expect(screen.getByLabelText("Archive task")).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["triage", "todo", "in-progress", "in-review", "done"] as const)(
|
||||
"renders archive action for %s tasks",
|
||||
(column) => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Archive task")).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not render archive action for archived tasks", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "archived" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
|
||||
onUnarchiveTask={vi.fn(async () => makeTask({ column: "done" }))}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Archive task")).toBeNull();
|
||||
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps two-button delete flow for non-done task", async () => {
|
||||
const onDeleteTask = vi.fn(async () => makeTask());
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
@@ -1963,10 +1994,10 @@ describe("TaskCard", () => {
|
||||
expect(actionsContainer?.contains(editBtn)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders archive button inside card-header-actions for done column", () => {
|
||||
it("renders archive button inside card-header-actions for live columns", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", size: "L" })}
|
||||
task={makeTask({ column: "todo", size: "L" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onArchiveTask={async () => makeTask()}
|
||||
|
||||
@@ -424,6 +424,32 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("offers archive instead when deleting a non-done live task", async () => {
|
||||
const onArchiveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "todo" as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
|
||||
});
|
||||
expect(noopDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries archive after lineage-conflict confirmation", async () => {
|
||||
const onArchiveTask = vi.fn();
|
||||
const conflict = new Error("Cannot archive task FN-099: still referenced as a lineage parent by FN-201.") as Error & {
|
||||
|
||||
@@ -1487,7 +1487,7 @@ describe("POST /tasks/:id/archive", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("archives a done task and returns the updated task", async () => {
|
||||
it("archives a task from any live column and returns the updated task", async () => {
|
||||
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
|
||||
|
||||
@@ -1537,15 +1537,15 @@ describe("POST /tasks/:id/archive", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is in 'triage', must be in 'done'"));
|
||||
it("returns 400 when task is already archived", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is already archived"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be in 'done'");
|
||||
expect(res.body.error).toContain("already archived");
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
|
||||
@@ -1785,7 +1785,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
// Archive task (done → archived)
|
||||
// Archive task (any live column → archived)
|
||||
router.post("/tasks/:id/archive", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
@@ -1814,12 +1814,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
});
|
||||
}
|
||||
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("must be in") ? 400 : 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status = message.includes("must be in") || message.includes("already archived") ? 400 : 500;
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
});
|
||||
|
||||
// Unarchive task (archived → done)
|
||||
// Unarchive task (archived → restored column)
|
||||
router.post("/tasks/:id/unarchive", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
"file": "packages/core/src/__tests__/task-node-override.test.ts",
|
||||
"reason": "Flake observed during FN-6324 verification: broad `pnpm test` failed with `Task FN-001 not found` after temp-root disappearance symptoms in adjacent core tests, alongside a leaked fusion-test-workers temp root. The task only changed agent role policy/settings/heartbeat routing, so this is unrelated temp redirect suite-order/concurrency sensitivity.",
|
||||
"quarantinedAt": "2026-06-12"
|
||||
},
|
||||
{
|
||||
"file": "packages/core/src/__tests__/db.test.ts",
|
||||
"reason": "Flake observed during FN-6299 verification: broad `pnpm --filter @fusion/core test` timed out in `Database.recoverIfCorrupt startup guard > rebuilds a malformed database and preserves the corrupt original` after 15s; earlier `pnpm test` attempt SIGTERM'd the core package and leaked a fusion-test-workers temp dir. Follow-up FN-6334.",
|
||||
"quarantinedAt": "2026-06-12"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user