diff --git a/.changeset/workflow-custom-columns-traits.md b/.changeset/workflow-custom-columns-traits.md index c267ef940b..ace3140c36 100644 --- a/.changeset/workflow-custom-columns-traits.md +++ b/.changeset/workflow-custom-columns-traits.md @@ -5,3 +5,5 @@ Add workflow-defined custom columns with composable traits, behind the `experimentalFeatures.workflowColumns` flag (off by default). Workflows can now define their own columns, each carrying composable traits (declarative flags plus lifecycle hooks) instead of the fixed `triage → todo → in-progress → in-review → done → archived` pipeline. The dashboard board renders one lane per workflow in use, and graphs gain `hold`, `split`, and `join` nodes for passive dwell and parallel fan-out/join branches. The built-in default workflow reproduces today's pipeline verbatim, and migration rewrites zero task rows — a null workflow selection resolves to the default workflow at read time. With the flag off, the legacy board, transitions, and engine behavior are unchanged. + +**ROLLBACK:** Workflow IR now has a `v2` on-disk shape (custom columns + `hold`/`split`/`join` nodes). Pre-v2 binaries hard-reject any IR whose `version !== 'v1'`, so a naive downgrade would brick rows that had been re-serialized as v2. To keep rollback safe, the store downgrades a workflow back to the `v1` shape on save whenever (a) the `experimentalFeatures.workflowColumns` flag is OFF, and (b) the graph is "pure v1" — only `start`/`prompt`/`script`/`gate`/`end` nodes, no `hold`/`split`/`join`, and exactly the synthesized default columns at their default seam-derived placement. v2 is persisted only when the flag is ON or a genuine v2 feature (custom column, applied trait, custom placement, or a v2-only node) is in use. Reading a downgraded `v1` row on a v2 binary re-upgrades it to the identical v2 graph, so this is lossless. Rollback is therefore only unsafe for workflows that actually use v2 features with the flag ON; turn the flag OFF and re-save such workflows (or delete them) before downgrading to a pre-v2 binary. diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 79db647e37..5528028e59 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { aiMergeTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -19,6 +19,12 @@ import { findNodeByNameOrId } from "./node.js"; const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"]; +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + // Register GitHub tracking hook so CLI task creation paths (add, duplicate, // refine, import, delegate) trigger tracking issue creation. try { @@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) { console.log(); console.log(` ${task.id}: ${task.title || task.description}`); - console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); + console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); if (task.dependencies.length) { console.log(` Dependencies: ${task.dependencies.join(", ")}`); } @@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri const task = await store.moveTask(id, column as Column); console.log(); - console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`); + console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) { const task = await store.archiveTask(id); console.log(); - console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) { const task = await store.unarchiveTask(id); console.log(); - console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`); console.log(); } diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7383485347..69c3111791 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -9,6 +9,7 @@ import { buildManualRetryResetPatch, validateNodeOverrideChange, type Task, + type ColumnId, type InsightCategory, type TaskPriority, type InsightStatus, @@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process"; // ── Helpers ──────────────────────────────────────────────────────── +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + const MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) { const lines: string[] = []; lines.push(`${task.id}: ${task.title || task.description}`); lines.push( - `Column: ${COLUMN_LABELS[task.column]}` + + `Column: ${columnLabel(task.column)}` + (task.size ? ` · Size: ${task.size}` : "") + (task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""), ); @@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.archiveTask(params.id); return { - content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, @@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.unarchiveTask(params.id); return { - content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index 27f17e95fa..7d78accce5 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -66,6 +66,60 @@ describe("TaskStore workflow definitions (U1)", () => { ).rejects.toThrow(/name is required/i); }); + describe("rollback compat — v1/v2 persistence (#1405)", () => { + function rawIr(id: string): { version: string } { + const row = (store as any).db + .prepare("SELECT ir FROM workflows WHERE id = ?") + .get(id) as { ir: string }; + return JSON.parse(row.ir); + } + + // A pure-v1 graph: only v1 node kinds, default columns at default placement. + const pureV1 = (): WorkflowIr => makeIr(); + + // A v2 graph using a custom column (a genuine v2 feature). + const v2Custom = (): WorkflowIr => + ({ + version: "v2", + name: "v2-feature", + columns: [ + { id: "triage", name: "triage", traits: [] }, + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "in-review", name: "in-review", traits: [] }, + { id: "done", name: "done", traits: [] }, + { id: "archived", name: "archived", traits: [] }, + { id: "review-queue", name: "Review Queue", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + }) as unknown as WorkflowIr; + + it("flag OFF: a pure-v1 workflow persists in the v1 shape on create and update", async () => { + const created = await store.createWorkflowDefinition({ name: "Pure", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + await store.updateWorkflowDefinition(created.id, { description: "edit", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + // Read-path still resolves it as the upgraded v2 in-memory shape. + const reloaded = await store.getWorkflowDefinition(created.id); + expect(reloaded?.ir.version).toBe("v2"); + }); + + it("flag OFF: a v2-feature workflow persists as v2 regardless", async () => { + const created = await store.createWorkflowDefinition({ name: "Feat", ir: v2Custom() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + + it("flag ON: a pure-v1 workflow persists as v2", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const created = await store.createWorkflowDefinition({ name: "OnFlag", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + }); + it("updates name, description, IR, and layout and advances updatedAt", async () => { const created = await store.createWorkflowDefinition({ name: "V1", ir: makeIr() }); await new Promise((r) => setTimeout(r, 2)); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 6bc7878e55..017678b613 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseWorkflowIr, serializeWorkflowIr, + downgradeIrToV1IfPure, WorkflowIrError, DEFAULT_WORKFLOW_COLUMN_IDS, } from "../workflow-ir.js"; @@ -130,6 +131,107 @@ describe("parseWorkflowIr — v1 upgrade", () => { }); }); +describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => { + const pureV1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + { from: "review", to: "end", condition: "success" }, + ], + }; + + it("downgrades an upgraded pure-v1 graph back to the v1 shape", () => { + const upgraded = parseWorkflowIr(pureV1); + expect(upgraded.version).toBe("v2"); + const down = downgradeIrToV1IfPure(upgraded); + expect(down.version).toBe("v1"); + // No synthesized `column` fields leak into the v1 shape. + expect(down.nodes.every((n) => n.column === undefined)).toBe(true); + // Lossless: a v2 binary re-upgrades it to the identical v2 graph. + expect(parseWorkflowIr(serializeWorkflowIr(down))).toEqual(upgraded); + }); + + it("pre-v2 binaries (version-only guard) accept the downgraded shape", () => { + const down = downgradeIrToV1IfPure(parseWorkflowIr(pureV1)); + expect(down.version).toBe("v1"); + // Simulate the pre-v2 hard reject of version !== 'v1'. + expect(() => { + if (down.version !== "v1") throw new WorkflowIrError("unsupported version"); + }).not.toThrow(); + }); + + it("keeps v2 when a v2-only node kind is present", () => { + const ir = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "wait", kind: "hold", column: "todo", config: { release: "manual" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "wait" }, + { from: "wait", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(ir)).version).toBe("v2"); + }); + + it("keeps v2 when columns are customized (rename / extra / applied trait)", () => { + const customName = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id === "todo" ? "Backlog" : id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(customName)).version).toBe("v2"); + + const withTrait = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ + id, + name: id, + traits: id === "todo" ? [{ trait: "intake" }] : [], + })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(withTrait)).version).toBe("v2"); + }); + + it("keeps v2 when a node is placed off its default seam column", () => { + const custom = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + // execute seam defaults to in-progress; place it in done instead. + { id: "exec", kind: "prompt", column: "done", config: { seam: "execute" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "exec" }, + { from: "exec", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(custom)).version).toBe("v2"); + }); + + it("returns a v1 input unchanged", () => { + expect(downgradeIrToV1IfPure(pureV1)).toBe(pureV1); + }); +}); + describe("parseWorkflowIr — hold release kinds", () => { const holdCols = [{ id: "c", name: "C", traits: [] }]; function holdIr(release: unknown): WorkflowIrV2 { diff --git a/packages/core/src/duplicate-detection.ts b/packages/core/src/duplicate-detection.ts index 9dd541b368..e4abd70874 100644 --- a/packages/core/src/duplicate-detection.ts +++ b/packages/core/src/duplicate-detection.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; -import type { Column } from "./types.js"; +import type { Column, ColumnId } from "./types.js"; export interface DuplicateMatch { id: string; title: string; description: string; - column: Column; + column: ColumnId; score: number; } @@ -19,7 +19,7 @@ export interface DuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; } export interface ContentFingerprintInput { @@ -138,7 +138,7 @@ export function findDuplicateMatches( const threshold = opts?.threshold ?? DEFAULT_THRESHOLD; const limit = opts?.limit ?? DEFAULT_LIMIT; - const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); + const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); const sourceText = `${input.title ?? ""} ${description}`.trim(); const sourceTokens = new Set(tokenize(sourceText)); const sourceTitle = input.title ?? ""; diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index e7a8994022..59ed39d583 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -1,5 +1,5 @@ import { findDuplicateMatches } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; import type { TaskStore } from "./store.js"; export interface SameAgentDuplicateInput { @@ -17,7 +17,7 @@ export interface SameAgentDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; createdAt: number; sourceAgentId: string | null; sourceParentTaskId?: string | null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dac2393945..67272ebed3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index 992ff1c99c..c9480d4826 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -1,5 +1,5 @@ import { STOPWORDS, tokenize } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_LIMIT = 5; @@ -34,7 +34,7 @@ export interface NearDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; fileScope?: string[]; createdAt?: number; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 8cd91e8b16..3a436d8903 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7,7 +7,7 @@ import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, 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 { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; -import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js"; +import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; import { @@ -691,7 +691,7 @@ function deepMergeWithNullDelete( export interface TaskStoreEvents { "task:created": [task: Task]; - "task:moved": [data: { task: Task; from: Column; to: Column; source: "user" | "engine" | "scheduler" }]; + "task:moved": [data: { task: Task; from: ColumnId; to: ColumnId; source: "user" | "engine" | "scheduler" }]; "task:updated": [task: Task]; "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; "task:merged": [result: MergeResult]; @@ -1081,7 +1081,7 @@ export class InvalidMergeQueueLeaseDurationError extends Error { export class HandoffInvariantViolationError extends Error { constructor( public readonly taskId: string, - public readonly fromColumn: Column, + public readonly fromColumn: ColumnId, message: string, ) { super(message); @@ -4406,7 +4406,7 @@ export class TaskStore extends EventEmitter { id: candidate.id, title: candidate.title ?? "", description: candidate.description, - column: "todo" as Column, + column: "todo", createdAt: Date.parse(candidate.createdAt), sourceAgentId: candidate.sourceAgentId, sourceParentTaskId: null, @@ -4891,8 +4891,9 @@ export class TaskStore extends EventEmitter { * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; - /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */ - column?: Column; + /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). + * Widened to {@link ColumnId} (#1403) so custom-column filters are accepted. */ + column?: ColumnId; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; }): Promise { @@ -5830,7 +5831,7 @@ export class TaskStore extends EventEmitter { // ColumnId admits workflow-defined custom column ids (KTD-1). Both paths // runtime-validate: flag-ON against the task's resolved workflow, flag-OFF // via the VALID_TRANSITIONS lookup (non-legacy ids reject as before). - return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn as Column, options, { fromHandoff: false })); + return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false })); } async handoffToReview(taskId: string, opts: HandoffToReviewOptions): Promise { @@ -5885,7 +5886,7 @@ export class TaskStore extends EventEmitter { private async moveTaskInternal( id: string, - toColumn: Column, + toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions, currentTask?: Task, @@ -6096,8 +6097,12 @@ export class TaskStore extends EventEmitter { !sourceIsLegacy && (COLUMNS as readonly string[]).includes(toColumn); if (!isEvacuation) { + // Legacy flag-OFF branch (useWorkflow === false): both columns are + // guaranteed legacy ids here — a non-legacy `toColumn` returns `?? []` + // and rejects below, and flag-OFF tasks never hold custom column ids. + // The `as Column` is provably safe within this branch (#1403). const validTargets = VALID_TRANSITIONS[task.column as Column] ?? []; - if (!validTargets.includes(toColumn)) { + if (!validTargets.includes(toColumn as Column)) { throw new Error( `Invalid transition: '${task.column}' → '${toColumn}'. ` + `Valid targets: ${validTargets.join(", ") || "none"}`, @@ -8242,7 +8247,7 @@ export class TaskStore extends EventEmitter { } } - private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: Column, nextColumn: Column, now: string): void { + private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: ColumnId, nextColumn: ColumnId, now: string): void { if (previousColumn !== "in-review" || nextColumn === "in-review") { return; } @@ -11984,6 +11989,9 @@ ${stepsSection}`; async createWorkflowDefinition( input: WorkflowDefinitionInput, ): Promise { + // Rollback compat (#1405): with the flag OFF, persist a pure-v1-equivalent + // graph in the v1 shape so a binary downgrade can still load the row. + const flagOnForCreate = await this.workflowColumnsFlagOn(); return this.withConfigLock(async () => { const name = input.name?.trim(); if (!name) throw new Error("Workflow name is required"); @@ -12014,7 +12022,9 @@ ${stepsSection}`; definition.id, definition.name, definition.description, - serializeWorkflowIr(definition.ir), + serializeWorkflowIr( + flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), + ), JSON.stringify(definition.layout), definition.createdAt, definition.updatedAt, @@ -12127,7 +12137,8 @@ ${stepsSection}`; .run( next.name, next.description, - serializeWorkflowIr(next.ir), + // Rollback compat (#1405): persist v1 shape when pure and flag OFF. + serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), JSON.stringify(next.layout), next.updatedAt, id, @@ -12309,7 +12320,7 @@ ${stepsSection}`; // Recovery-class move: engine source + bypassGuards (KTD-9). preserveProgress // keeps the task's fields intact (R20 delete semantics). Capacity (KTD-10) is // NOT bypassed — a full target column rejects, which we audit and skip. - await this.moveTask(taskId, targetColumn as Column, { + await this.moveTask(taskId, targetColumn, { moveSource: "engine", bypassGuards: true, recoveryRehome: true, diff --git a/packages/core/src/task-age-staleness.ts b/packages/core/src/task-age-staleness.ts index b55ed779f4..f4bf79c5dd 100644 --- a/packages/core/src/task-age-staleness.ts +++ b/packages/core/src/task-age-staleness.ts @@ -50,6 +50,10 @@ export function getTaskAgeStalenessSignal( if (task.column !== "in-progress" && task.column !== "in-review") { return undefined; } + // The guard above proves `column` is one of these two legacy ids; the + // `ColumnId` union's `string & {}` member can't be excluded by literal `!==` + // narrowing, so the cast is provably safe here (#1403). + const activeColumn = task.column as "in-progress" | "in-review"; if (task.mergeDetails?.mergeConfirmed === true) { return undefined; } @@ -105,7 +109,7 @@ export function getTaskAgeStalenessSignal( ageMs, warningThresholdMs: warningThresholdMs ?? 0, criticalThresholdMs: criticalThresholdMs ?? 0, - column: task.column, + column: activeColumn, paused: task.paused === true, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec1ba67da2..41ea3f2de9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1809,7 +1809,9 @@ export interface Task { * tasks are hydrated from persistence. */ priority?: TaskPriority; - column: Column; + /** 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; dependencies: string[]; /** User-requested hint for triage: prefer splitting into child tasks when appropriate. */ breakIntoSubtasks?: boolean; @@ -2206,7 +2208,9 @@ export interface TaskCreateInput { * Optional task importance level. Omitted values default to `normal`. */ priority?: TaskPriority; - column?: Column; + /** Initial column id. Widened to {@link ColumnId} (#1403) so a custom-column + * task can be replicated/created; flag-OFF creation only ever uses legacy ids. */ + column?: ColumnId; dependencies?: string[]; breakIntoSubtasks?: boolean; /** When true, this task is expected to complete without creating git commits. */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 5127a00e70..95ed2ac100 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -3,6 +3,7 @@ import type { WorkflowIrColumn, WorkflowIrEdge, WorkflowIrNode, + WorkflowIrNodeKind, WorkflowIrV1, WorkflowIrV2, WorkflowHoldRelease, @@ -252,6 +253,62 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr { return ir; } +/** v1 node kinds (FN-5769). A pure-v1 graph uses only these; the v2-only kinds + * (hold/split/join) force v2 persistence. */ +const V1_NODE_KINDS: ReadonlySet = new Set([ + "start", + "prompt", + "script", + "gate", + "end", +]); + +/** + * Rollback compat (FN issue #1405): if `ir` is a v2 graph that is byte-for-byte + * equivalent to an upgraded-v1 graph — only v1 node kinds, no hold/split/join, + * and exactly the synthesized default columns at their seam-derived placement — + * downgrade it back to the v1 shape so pre-v2 binaries (which hard-reject + * version !== 'v1') can still load the row. Returns the original `ir` unchanged + * when any v2-only feature is present (custom columns, non-default placement, + * v2-only node kinds), since those genuinely require v2. + */ +export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { + if (ir.version !== "v2") return ir; + + // Any v2-only node kind means the graph cannot be represented in v1. + for (const node of ir.nodes) { + if (!V1_NODE_KINDS.has(node.kind)) return ir; + } + + // Columns must be exactly the synthesized default set, same ids, same order, + // with the minimal (placement-only) empty trait set. Any custom column, rename, + // reorder, or applied trait forces v2. + if (ir.columns.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return ir; + for (let i = 0; i < ir.columns.length; i++) { + const col = ir.columns[i]; + const expectedId = DEFAULT_WORKFLOW_COLUMN_IDS[i]; + if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) { + return ir; + } + } + + // Every node must sit in its default seam-derived column. A node placed + // elsewhere is a v2 feature (custom placement) and must stay v2. + for (const node of ir.nodes) { + if (node.column !== defaultColumnForNode(node)) return ir; + } + + // Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the + // result round-trips through a pre-v2 binary. (Re-reading it on a v2 binary + // re-upgrades it to the identical v2 graph via upgradeV1ToV2.) + return { + version: "v1", + name: ir.name, + nodes: ir.nodes.map(({ column: _column, ...rest }) => rest), + edges: ir.edges, + }; +} + export function serializeWorkflowIr(ir: WorkflowIr): string { return JSON.stringify(ir, null, 2); } diff --git a/packages/dashboard/app/components/ChangesDiffModal.tsx b/packages/dashboard/app/components/ChangesDiffModal.tsx index 4ebcd553e2..e9147c1a85 100644 --- a/packages/dashboard/app/components/ChangesDiffModal.tsx +++ b/packages/dashboard/app/components/ChangesDiffModal.tsx @@ -11,7 +11,7 @@ import { RefreshCw, GitCommit, } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { highlightDiff } from "../utils/highlightDiff"; import "./TaskDiffShared.css"; import "./ChangesDiffModal.css"; @@ -31,7 +31,7 @@ interface ChangesDiffModalProps { files: NormalizedFile[]; stats: { filesChanged: number; additions: number; deletions: number }; mergeDetails?: MergeDetails; - column?: Column; + column?: ColumnId; onClose: () => void; onRefresh?: () => void; } diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 1092da9045..e3fbd0903f 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react"; -import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; @@ -30,6 +30,12 @@ const COLUMN_COLOR_MAP: Record = { archived: "var(--text-dim)", }; +/** #1403: resolve a column color by id; workflow-defined custom columns that + * have no legacy color fall back to the neutral accent rather than `undefined`. */ +function columnColor(column: ColumnId): string { + return (COLUMN_COLOR_MAP as Record)[column] ?? "var(--accent)"; +} + const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); type SortField = "title" | "status" | "column" | "retries"; @@ -1811,7 +1817,7 @@ export function ListView({ className="list-progress-fill" style={{ width: `${taskProgress.percent}%`, - backgroundColor: COLUMN_COLOR_MAP[task.column], + backgroundColor: columnColor(task.column), }} /> @@ -2009,8 +2015,8 @@ export function ListView({ {columnLabel(task.column)} @@ -2043,7 +2049,7 @@ export function ListView({ className="list-progress-fill" style={{ width: `${taskProgress.percent}%`, - backgroundColor: COLUMN_COLOR_MAP[task.column], + backgroundColor: columnColor(task.column), }} /> diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 5f1fe3afbb..e50dcc9130 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react"; -import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, @@ -144,7 +144,9 @@ function isAgentCreatedTask(task: Task): boolean { // ── Constants ─────────────────────────────────────────────────────────────── -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (which are not members and correctly resolve to false). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]); @@ -158,7 +160,7 @@ const COLUMN_PROGRESS_COLOR_MAP: Record = { archived: "var(--text-muted)", }; -const TIME_INDICATOR_COLUMNS = new Set([ +const TIME_INDICATOR_COLUMNS = new Set([ "in-progress", "in-review", "done", @@ -2002,7 +2004,9 @@ function TaskCardComponent({ className="card-progress-fill" style={{ width: `${progressPercent}%`, - backgroundColor: COLUMN_PROGRESS_COLOR_MAP[task.column], + // #1403: custom columns have no legacy progress color → fall back to accent. + backgroundColor: + (COLUMN_PROGRESS_COLOR_MAP as Record)[task.column] ?? "var(--accent)", }} /> diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 784b2bb281..ec117c7cfb 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchTaskDiff, @@ -16,7 +16,7 @@ interface TaskChangesTabProps { taskId: string; worktree?: string; projectId?: string; - column?: Column; + column?: ColumnId; mergeDetails?: MergeDetails; /** * Files modified by the task during execution, captured from the worktree. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index bdc56eb52e..c6549955b9 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -9,12 +9,13 @@ import { useColumnLabel } from "../i18n/labels"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, TASK_PRIORITIES, VALID_TRANSITIONS, + isColumn, getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, @@ -456,8 +457,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt const DESCRIPTION_TRUNCATE_LENGTH = 200; -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); -const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); +// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (non-members correctly resolve to false → not editable). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); export function TaskDetailContent({ task, @@ -2229,7 +2232,9 @@ export function TaskDetailContent({ return providers; }, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]); - const transitions = VALID_TRANSITIONS[task.column] || []; + // #1403: legacy transitions only exist for legacy columns; a custom column id + // has no VALID_TRANSITIONS row, so the move menu shows no legacy targets. + const transitions: Column[] = isColumn(task.column) ? [...VALID_TRANSITIONS[task.column]] : []; const inReviewMoveTransitions: Column[] = ["todo", "in-progress"]; const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions; const primaryMoveTransition = moveTransitions[0]; diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 118afe699d..46c40c3ede 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import type { Task, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; +import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import { normalizeColumn } from "@fusion/core"; import * as api from "../api"; import { subscribeSse } from "../sse-bus"; @@ -359,14 +359,18 @@ export function useTasks(options?: UseTasksOptions) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } - const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data); + // #1403: the move event carries `ColumnId` (custom column ids admitted). + const { task, to }: { task: Task; from: ColumnId; to: ColumnId } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); if (isSoftDeleted(normalizedTask)) { setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:moved", id: normalizedTask.id }); return; } - const movedTask = { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) }; + // Preserve a custom (non-legacy) target id verbatim; only coerce empty/garbage + // back to the task's current column. normalizeColumn alone would drop custom ids. + const nextColumn: ColumnId = typeof to === "string" && to ? to : normalizedTask.column; + const movedTask = { ...normalizedTask, column: nextColumn }; setTasks((prev) => { const existingIndex = prev.findIndex((t) => t.id === movedTask.id); if (existingIndex === -1) { diff --git a/packages/dashboard/app/i18n/labels.ts b/packages/dashboard/app/i18n/labels.ts index 6f9a8981dc..e3e2295aeb 100644 --- a/packages/dashboard/app/i18n/labels.ts +++ b/packages/dashboard/app/i18n/labels.ts @@ -1,4 +1,4 @@ -import { COLUMN_LABELS, type Column } from "@fusion/core"; +import { COLUMN_LABELS, type ColumnId } from "@fusion/core"; import { useTranslation } from "react-i18next"; /** @@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next"; * keys with the English `COLUMN_LABELS` as the fallback. This is the migration * pattern for the centralized core label constants: import the hook, call it, * and replace `COLUMN_LABELS[col]` with `columnLabel(col)`. + * + * #1403: accepts a {@link ColumnId}; workflow-defined custom columns that have + * no legacy label or i18n key fall back to displaying the raw id. */ -export function useColumnLabel(): (column: Column) => string { +export function useColumnLabel(): (column: ColumnId) => string { const { t } = useTranslation("common"); - return (column: Column) => t(`columns.${column}`, COLUMN_LABELS[column]); + return (column: ColumnId) => + t(`columns.${column}`, (COLUMN_LABELS as Record)[column] ?? column); } diff --git a/packages/dashboard/src/github-source-issue-close.ts b/packages/dashboard/src/github-source-issue-close.ts index b7abfeb573..77dd284ba2 100644 --- a/packages/dashboard/src/github-source-issue-close.ts +++ b/packages/dashboard/src/github-source-issue-close.ts @@ -3,8 +3,6 @@ import { resolveGithubTrackingAuth } from "./github-auth.js"; import { GitHubClient } from "./github.js"; import { delay, isTransientGitHubError } from "./github-tracking-state.js"; -type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived"; - interface TaskMovedEvent { task: { id: string; @@ -14,8 +12,10 @@ interface TaskMovedEvent { issueNumber?: number; }; }; - from: Column; - to: Column; + // #1403: store's `task:moved` carries `ColumnId`; this handler only + // literal-compares legacy ids, so the widened string field is safe. + from: string; + to: string; } export class GitHubSourceIssueCloseService { diff --git a/packages/dashboard/src/github-tracking-state.ts b/packages/dashboard/src/github-tracking-state.ts index 6664a07b39..02112e3d30 100644 --- a/packages/dashboard/src/github-tracking-state.ts +++ b/packages/dashboard/src/github-tracking-state.ts @@ -4,8 +4,6 @@ import { resolveGithubTrackingAuth } from "./github-auth.js"; const TRANSIENT_RETRY_DELAY_MS = 25; -type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived"; - interface TaskMovedEvent { task: { id: string; @@ -21,13 +19,16 @@ interface TaskMovedEvent { }; }; }; - from: Column; - to: Column; + // #1403: the store's `task:moved` event now carries `ColumnId` (custom column + // ids admitted). These handlers only literal-compare against legacy ids, so a + // string-widened field is safe. + from: string; + to: string; } export function decideIssueAction( - from: Column, - to: Column, + from: string, + to: string, ): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null { if (to === "done" && from !== "done") { return { action: "close", stateReason: "completed" }; diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 0bb8c13823..df4a2b8e18 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -2746,7 +2746,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // Check if task can transition to triage - const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); + // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy + // closed union. A non-legacy custom column id has no legacy transition row, + // so it correctly resolves to "cannot transition" here. + const canTransition = + isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); if (!canTransition) { throw badRequest( `Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`, @@ -2812,7 +2816,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // Check if task can transition to triage - const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); + // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy + // closed union. A non-legacy custom column id has no legacy transition row, + // so it correctly resolves to "cannot transition" here. + const canTransition = + isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); if (!canTransition) { throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`); } diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index bba14e30eb..cbdcc1ece6 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -256,7 +256,7 @@ import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "../wo import { createFnAgent } from "../pi.js"; import { execSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; -import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@fusion/core"; +import type { Task, TaskDetail, TaskStep, Column, ColumnId, Settings, StepStatus } from "@fusion/core"; const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedExecSync = vi.mocked(execSync); @@ -320,7 +320,7 @@ function createMockStore(overrides: Record = {}) { return store; } -function makeTask(id: string, column: Column, overrides: Partial = {}): Task { +function makeTask(id: string, column: ColumnId, overrides: Partial = {}): Task { return { id, title: `Task ${id}`, @@ -336,7 +336,7 @@ function makeTask(id: string, column: Column, overrides: Partial = {}): Ta }; } -function makeTaskDetail(id: string, column: Column, overrides: Partial = {}): TaskDetail { +function makeTaskDetail(id: string, column: ColumnId, overrides: Partial = {}): TaskDetail { return { ...makeTask(id, column, overrides), prompt: overrides.prompt ?? "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n## Review Level: 0", diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index d45955936b..35afa2e55a 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -1981,7 +1981,7 @@ export class Scheduler { * lifecycle, including review/merge transitions and older tasks whose task * row has mission/slice metadata but whose feature row lacks taskId. */ - private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").Column): Promise { + private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").ColumnId): Promise { if (!this.options.missionStore) return; const missionStore = this.options.missionStore; diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 5f39f7c3f6..9f9441356a 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -2,7 +2,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs"; import { basename, join, relative, resolve, isAbsolute } from "node:path"; -import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; +import type { Column, ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js"; import { worktreePoolLog } from "./logger.js"; import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js"; @@ -943,7 +943,7 @@ export async function reapOrphanWorktrees( } /** Columns where merger/finalization owns branch lifecycle. */ -const MERGER_MANAGED_COLUMNS: ReadonlySet = new Set(["in-review", "done"]); +const MERGER_MANAGED_COLUMNS: ReadonlySet = new Set(["in-review", "done"]); /** * Return local `fusion/*` branches not associated with any active task.