fix(core): widen Task.column to ColumnId across packages; v1-preserving IR persistence for rollback safety (#1403 #1405)

This commit is contained in:
gsxdsm
2026-06-04 08:00:33 -07:00
parent cfc9bb5fd5
commit afc72b6bb0
26 changed files with 353 additions and 74 deletions

View File

@@ -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.

View File

@@ -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<string, string>)[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();
}

View File

@@ -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<string, string>)[column] ?? column;
}
const MIME_TYPES: Record<string, string> = {
".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 },
};
},

View File

@@ -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));

View File

@@ -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 {

View File

@@ -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<ColumnId>(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS);
const sourceText = `${input.title ?? ""} ${description}`.trim();
const sourceTokens = new Set(tokenize(sourceText));
const sourceTitle = input.title ?? "";

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<TaskStoreEvents> {
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<TaskStoreEvents> {
* 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<Task[]> {
@@ -5830,7 +5831,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// 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<Task> {
@@ -5885,7 +5886,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
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<TaskStoreEvents> {
!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<TaskStoreEvents> {
}
}
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<WorkflowDefinition> {
// 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,

View File

@@ -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,
};
}

View File

@@ -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. */

View File

@@ -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<WorkflowIrNodeKind> = 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);
}

View File

@@ -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;
}

View File

@@ -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<Column, string> = {
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<string, string>)[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),
}}
/>
</div>
@@ -2009,8 +2015,8 @@ export function ListView({
<span
className="list-column-badge"
style={{
background: `color-mix(in srgb, ${COLUMN_COLOR_MAP[task.column]} 12%, transparent)`,
color: COLUMN_COLOR_MAP[task.column],
background: `color-mix(in srgb, ${columnColor(task.column)} 12%, transparent)`,
color: columnColor(task.column),
}}
>
{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),
}}
/>
</div>

View File

@@ -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<Column> = 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<ColumnId> = new Set<ColumnId>(["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<Column, string> = {
archived: "var(--text-muted)",
};
const TIME_INDICATOR_COLUMNS = new Set<Column>([
const TIME_INDICATOR_COLUMNS = new Set<ColumnId>([
"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<string, string>)[task.column] ?? "var(--accent)",
}}
/>
</div>

View File

@@ -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.

View File

@@ -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<Column> = new Set(["triage", "todo"]);
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<Column> = 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<ColumnId> = new Set<ColumnId>(["triage", "todo"]);
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["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];

View File

@@ -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) {

View File

@@ -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<string, string>)[column] ?? column);
}

View File

@@ -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 {

View File

@@ -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" };

View File

@@ -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.`);
}

View File

@@ -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<string, any> = {}) {
return store;
}
function makeTask(id: string, column: Column, overrides: Partial<Task> = {}): Task {
function makeTask(id: string, column: ColumnId, overrides: Partial<Task> = {}): Task {
return {
id,
title: `Task ${id}`,
@@ -336,7 +336,7 @@ function makeTask(id: string, column: Column, overrides: Partial<Task> = {}): Ta
};
}
function makeTaskDetail(id: string, column: Column, overrides: Partial<TaskDetail> = {}): TaskDetail {
function makeTaskDetail(id: string, column: ColumnId, overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
...makeTask(id, column, overrides),
prompt: overrides.prompt ?? "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n## Review Level: 0",

View File

@@ -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<void> {
private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").ColumnId): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;

View File

@@ -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<Column> = new Set(["in-review", "done"]);
const MERGER_MANAGED_COLUMNS: ReadonlySet<ColumnId> = new Set<ColumnId>(["in-review", "done"]);
/**
* Return local `fusion/*` branches not associated with any active task.