fix(core): widen Task.column to ColumnId across packages; v1-preserving IR persistence for rollback safety (#1403 #1405)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user