feat(FN-2471): persist source issue provenance in task storage

- Add TaskSourceIssue contract and thread sourceIssue through Task, TaskCreateInput, archived task entries, and TaskStore serialization paths.
- Extend SQLite schema to v45 with sourceIssue* columns and add migration coverage for v44 upgrades plus legacy JSON migration import.
- Persist, update, clear, and archive/unarchive sourceIssue metadata in TaskStore with dedicated regression tests.
- Update core and dashboard tests to schema v45 expectations and stabilize flaky modal assertions with async waits.
This commit is contained in:
Fusion
2026-04-24 12:14:56 -07:00
committed by gsxdsm
parent 0625f4a64a
commit 62cb15a4b8
15 changed files with 269 additions and 34 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
const index = db
.prepare(

View File

@@ -586,6 +586,13 @@ describe("migrateFromLegacy", () => {
workflowStepResults: [{ workflowStepId: "WS-001", workflowStepName: "QA", status: "passed" }],
prInfo: { url: "https://github.com/test/pr/1", number: 1, status: "open", title: "PR", headBranch: "feature", baseBranch: "main", commentCount: 3 },
issueInfo: { url: "https://github.com/test/issues/1", number: 10, state: "open", title: "Issue" },
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 10,
url: "https://github.com/test/issues/1",
},
breakIntoSubtasks: true,
enabledWorkflowSteps: ["WS-001", "WS-002"],
};
@@ -629,6 +636,11 @@ describe("migrateFromLegacy", () => {
expect(JSON.parse(row.workflowStepResults)).toHaveLength(1);
expect(JSON.parse(row.prInfo).number).toBe(1);
expect(JSON.parse(row.issueInfo).number).toBe(10);
expect(row.sourceIssueProvider).toBe("github");
expect(row.sourceIssueRepository).toBe("runfusion/fusion");
expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample");
expect(row.sourceIssueNumber).toBe(10);
expect(row.sourceIssueUrl).toBe("https://github.com/test/issues/1");
expect(row.breakIntoSubtasks).toBe(1);
expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]);
});

View File

@@ -222,11 +222,12 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
mergeRetries, recoveryRetryCount, nextRecoveryAt,
error, summary, thinkingLevel, createdAt, updatedAt,
columnMovedAt, dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -283,6 +284,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
task.sourceIssue?.provider ?? null,
task.sourceIssue?.repository ?? null,
task.sourceIssue?.externalIssueId ?? null,
task.sourceIssue?.issueNumber ?? null,
task.sourceIssue?.url ?? null,
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
db.close();
});
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -899,6 +899,72 @@ describe("schema migrations", () => {
db.close();
});
it("migrates v44 databases by adding source issue columns with null-compatible defaults", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(fusionDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
tokenUsageInputTokens INTEGER,
tokenUsageOutputTokens INTEGER,
tokenUsageCachedTokens INTEGER,
tokenUsageTotalTokens INTEGER,
tokenUsageFirstUsedAt TEXT,
tokenUsageLastUsedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '44')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-3', 'legacy v44', 'todo', '2026-01-01', '2026-01-01')`);
db.init();
expect(db.getSchemaVersion()).toBe(45);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
expect(colNames).toContain("sourceIssueProvider");
expect(colNames).toContain("sourceIssueRepository");
expect(colNames).toContain("sourceIssueExternalIssueId");
expect(colNames).toContain("sourceIssueNumber");
expect(colNames).toContain("sourceIssueUrl");
const task = db.prepare(`
SELECT
sourceIssueProvider,
sourceIssueRepository,
sourceIssueExternalIssueId,
sourceIssueNumber,
sourceIssueUrl
FROM tasks
WHERE id = 'FN-3'
`).get() as Record<string, null>;
expect(task.sourceIssueProvider).toBeNull();
expect(task.sourceIssueRepository).toBeNull();
expect(task.sourceIssueExternalIssueId).toBeNull();
expect(task.sourceIssueNumber).toBeNull();
expect(task.sourceIssueUrl).toBeNull();
db.close();
});
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
@@ -910,7 +976,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -934,7 +1000,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1038,7 +1104,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1407,7 +1473,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 44;
const SCHEMA_VERSION = 45;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -199,6 +199,11 @@ CREATE TABLE IF NOT EXISTS tasks (
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
sourceIssueProvider TEXT,
sourceIssueRepository TEXT,
sourceIssueExternalIssueId TEXT,
sourceIssueNumber INTEGER,
sourceIssueUrl TEXT,
mergeDetails TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]',
@@ -1735,6 +1740,19 @@ export class Database {
});
}
// Source issue provenance contract (FN-2471)
// Persists durable source identity for imported issues separately from
// transient/live issueInfo status snapshots.
if (version < 45) {
this.applyMigration(45, () => {
this.addColumnIfMissing("tasks", "sourceIssueProvider", "TEXT");
this.addColumnIfMissing("tasks", "sourceIssueRepository", "TEXT");
this.addColumnIfMissing("tasks", "sourceIssueExternalIssueId", "TEXT");
this.addColumnIfMissing("tasks", "sourceIssueNumber", "INTEGER");
this.addColumnIfMissing("tasks", "sourceIssueUrl", "TEXT");
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(44);
expect(db1.getSchemaVersion()).toBe(45);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -811,7 +811,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(44);
expect(db3.getSchemaVersion()).toBe(45);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(44);
expect(db1.getSchemaVersion()).toBe(45);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(44);
expect(db2.getSchemaVersion()).toBe(45);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2626,7 +2626,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
});
it("mission_features table has loop state columns", () => {

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(44);
expect(db.getSchemaVersion()).toBe(45);
});
});
});

View File

@@ -57,6 +57,16 @@ describe("TaskStore", () => {
return store.createTask({ description: "Test task" });
}
function createSourceIssueFixture() {
return {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2471,
url: "https://github.com/runfusion/fusion/issues/2471",
};
}
async function createTaskWithSteps(): Promise<Task> {
const task = await store.createTask({ description: "Task with steps" });
// Write a PROMPT.md with steps so updateStep works
@@ -3443,6 +3453,58 @@ describe("TaskStore", () => {
expect(updated.title).toBe("Updated title");
});
it("persists sourceIssue on create and reload", async () => {
const sourceIssue = createSourceIssueFixture();
const created = await store.createTask({
description: "Task with source issue",
sourceIssue,
});
expect(created.sourceIssue).toEqual(sourceIssue);
const reloaded = await store.getTask(created.id);
expect(reloaded.sourceIssue).toEqual(sourceIssue);
});
it("updates and clears sourceIssue via updateTask", async () => {
const sourceIssue = createSourceIssueFixture();
const task = await createTestTask();
const linked = await store.updateTask(task.id, { sourceIssue });
expect(linked.sourceIssue).toEqual(sourceIssue);
const reloaded = await store.getTask(task.id);
expect(reloaded.sourceIssue).toEqual(sourceIssue);
const cleared = await store.updateTask(task.id, { sourceIssue: null });
expect(cleared.sourceIssue).toBeUndefined();
const reloadedAfterClear = await store.getTask(task.id);
expect(reloadedAfterClear.sourceIssue).toBeUndefined();
});
it("preserves sourceIssue through archive and unarchive", async () => {
const sourceIssue = createSourceIssueFixture();
const task = await store.createTask({
description: "Archive source issue preservation",
sourceIssue,
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const archived = await store.getTask(task.id);
expect(archived.column).toBe("archived");
expect(archived.sourceIssue).toEqual(sourceIssue);
const restored = await store.unarchiveTask(task.id);
expect(restored.column).toBe("done");
expect(restored.sourceIssue).toEqual(sourceIssue);
});
it("sets and clears mission linkage fields via updateTask", async () => {
const task = await createTestTask();

View File

@@ -74,6 +74,11 @@ interface TaskRow {
workflowStepResults: string | null;
prInfo: string | null;
issueInfo: string | null;
sourceIssueProvider: string | null;
sourceIssueRepository: string | null;
sourceIssueExternalIssueId: string | null;
sourceIssueNumber: number | null;
sourceIssueUrl: string | null;
mergeDetails: string | null;
breakIntoSubtasks: number | null;
enabledWorkflowSteps: string | null;
@@ -531,6 +536,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
sourceIssue: (() => {
if (
row.sourceIssueProvider === null
|| row.sourceIssueRepository === null
|| row.sourceIssueExternalIssueId === null
|| row.sourceIssueNumber === null
) {
return undefined;
}
return {
provider: row.sourceIssueProvider,
repository: row.sourceIssueRepository,
externalIssueId: row.sourceIssueExternalIssueId,
issueNumber: row.sourceIssueNumber,
url: row.sourceIssueUrl ?? undefined,
};
})(),
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
@@ -558,6 +581,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewLevel: entry.reviewLevel,
prInfo: slim ? undefined : entry.prInfo,
issueInfo: slim ? undefined : entry.issueInfo,
sourceIssue: slim ? undefined : entry.sourceIssue,
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
log: slim ? [] : entry.log ?? [],
@@ -677,6 +701,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
issueInfo: task.issueInfo,
sourceIssue: task.sourceIssue,
attachments: task.attachments,
comments: task.comments,
prompt,
@@ -756,7 +781,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "mergeDetails",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId",
"checkedOutBy", "checkedOutAt",
@@ -775,7 +800,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "mergeDetails",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId",
"checkedOutBy", "checkedOutAt",
@@ -816,10 +841,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -872,6 +898,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
workflowStepResults = excluded.workflowStepResults,
prInfo = excluded.prInfo,
issueInfo = excluded.issueInfo,
sourceIssueProvider = excluded.sourceIssueProvider,
sourceIssueRepository = excluded.sourceIssueRepository,
sourceIssueExternalIssueId = excluded.sourceIssueExternalIssueId,
sourceIssueNumber = excluded.sourceIssueNumber,
sourceIssueUrl = excluded.sourceIssueUrl,
mergeDetails = excluded.mergeDetails,
breakIntoSubtasks = excluded.breakIntoSubtasks,
enabledWorkflowSteps = excluded.enabledWorkflowSteps,
@@ -934,6 +965,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
task.sourceIssue?.provider ?? null,
task.sourceIssue?.repository ?? null,
task.sourceIssue?.externalIssueId ?? null,
task.sourceIssue?.issueNumber ?? null,
task.sourceIssue?.url ?? null,
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
@@ -1883,6 +1919,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
description: input.description,
priority: normalizeTaskPriority(input.priority),
tokenUsage: input.tokenUsage,
sourceIssue: input.sourceIssue,
column: input.column || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
@@ -2451,7 +2488,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2645,6 +2682,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.mergeDetails !== undefined) {
task.mergeDetails = updates.mergeDetails;
}
if (updates.sourceIssue === null) {
task.sourceIssue = undefined;
} else if (updates.sourceIssue !== undefined) {
task.sourceIssue = updates.sourceIssue;
}
if (updates.tokenUsage === null) {
task.tokenUsage = undefined;
} else if (updates.tokenUsage !== undefined) {
@@ -4921,6 +4963,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewLevel: entry.reviewLevel,
prInfo: entry.prInfo,
issueInfo: entry.issueInfo,
sourceIssue: entry.sourceIssue,
attachments: entry.attachments,
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],
comments: entry.comments,

View File

@@ -441,6 +441,26 @@ export interface IssueInfo {
lastCheckedAt?: string;
}
/**
* Durable provenance metadata for tasks imported from external issue trackers.
*
* Distinct from {@link IssueInfo}, which captures live issue status snapshots.
* This contract stores source identity so the originating issue can be
* re-associated even when live status is unavailable.
*/
export interface TaskSourceIssue {
/** Issue provider key (for example: "github", "gitlab", "jira"). */
provider: string;
/** Repository/project identifier in provider-specific canonical form. */
repository: string;
/** Stable provider-specific external issue identifier (string to support non-numeric IDs). */
externalIssueId: string;
/** Human-visible issue number in the source tracker. */
issueNumber: number;
/** Optional canonical URL to the source issue. */
url?: string;
}
export interface BatchStatusRequest {
taskIds: string[];
}
@@ -731,6 +751,8 @@ export interface Task {
mergeDetails?: MergeDetails;
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
log: TaskLogEntry[];
/** Durable aggregate token usage totals for the task. Undefined when no usage has been recorded yet. */
tokenUsage?: TaskTokenUsage;
@@ -843,6 +865,8 @@ export interface InboxTask {
export interface TaskCreateInput {
title?: string;
description: string;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Optional persisted aggregate token usage snapshot for task creation/import paths. */
tokenUsage?: TaskTokenUsage;
/**
@@ -1616,6 +1640,8 @@ export interface ArchivedTaskEntry {
executionMode?: ExecutionMode;
prInfo?: PrInfo;
issueInfo?: IssueInfo;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Attachment metadata (filenames, mime types, etc.) without file content */
attachments?: TaskAttachment[];
/** User and agent comments remain searchable in the archive DB. */

View File

@@ -3678,9 +3678,11 @@ describe("ModelOnboardingModal progressive disclosure", () => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const aiProviderItem = getReadinessItem("AI Provider");
expect(aiProviderItem).toHaveAttribute("data-status", "skipped");
expect(aiProviderItem).toHaveTextContent(/AI agents won't be available/i);
await waitFor(() => {
const aiProviderItem = getReadinessItem("AI Provider");
expect(aiProviderItem).toHaveAttribute("data-status", "skipped");
expect(aiProviderItem).toHaveTextContent(/AI agents won't be available/i);
});
});
it("shows GitHub as missing when available but not connected", async () => {

View File

@@ -3078,7 +3078,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(await screen.findByText("Scheduling"));
const checkbox = screen.getByLabelText("Enable automatic task archiving");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");