feat(FN-3869): add github tracking fields and resolver to tasks

Adds GitHub tracking fields to the task model with schema migration, store accessors, and type definitions in `@fusion/core`. New `github-tracking.ts` module exposes a resolver for fetching GitHub issue/PR metadata, integrated into the task store. Test coverage spans the new module, store integratio

Fusion-Task-Id: FN-3869
This commit is contained in:
Fusion
2026-05-09 20:42:05 -07:00
committed by gsxdsm
parent 24aef31b2f
commit 46efd0043b
17 changed files with 543 additions and 32 deletions

View File

@@ -646,3 +646,46 @@ describe("migrateFromLegacy", () => {
});
});
});
describe("schema migration", () => {
let tmpDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("adds tasks.githubTracking when migrating from schema version 70", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
issueInfo TEXT
)
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '70')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt, issueInfo) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '{\"number\":1}')`);
db.init();
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("githubTracking");
const row = db.prepare("SELECT id, issueInfo FROM tasks WHERE id = 'FN-legacy'").get() as { id: string; issueInfo: string };
expect(row.id).toBe("FN-legacy");
expect(JSON.parse(row.issueInfo).number).toBe(1);
db.close();
});
});

View File

@@ -175,7 +175,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
@@ -197,7 +197,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -996,7 +996,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1021,11 +1021,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
db.close();
});
@@ -1060,7 +1060,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1101,7 +1101,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1170,7 +1170,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1410,7 +1410,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1484,7 +1484,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
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" }]);
@@ -1508,7 +1508,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
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" }]);
@@ -1612,7 +1612,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2081,7 +2081,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(70);
expect(db.getSchemaVersion()).toBe(71);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2214,7 +2214,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(70);
expect(migrated.getSchemaVersion()).toBe(71);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2228,7 +2228,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
fresh.init();
expect(fresh.getSchemaVersion()).toBe(70);
expect(fresh.getSchemaVersion()).toBe(71);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { isValidRepoSlug, parseRepoSlug, resolveTaskGithubTracking } from "../github-tracking.js";
describe("parseRepoSlug", () => {
it("parses valid owner/repo slugs", () => {
expect(parseRepoSlug("octocat/hello-world")).toEqual({ owner: "octocat", repo: "hello-world" });
});
it("accepts leading and trailing whitespace", () => {
expect(parseRepoSlug(" octocat/hello-world ")).toEqual({ owner: "octocat", repo: "hello-world" });
});
it("rejects malformed values", () => {
expect(parseRepoSlug("octocat")).toBeNull();
expect(parseRepoSlug("octocat//hello-world")).toBeNull();
expect(parseRepoSlug("/hello-world")).toBeNull();
expect(parseRepoSlug("octocat/")).toBeNull();
expect(parseRepoSlug(null)).toBeNull();
expect(parseRepoSlug(undefined)).toBeNull();
});
it("rejects values with spaces in segments", () => {
expect(parseRepoSlug("octo cat/hello-world")).toBeNull();
expect(parseRepoSlug("octocat/hello world")).toBeNull();
});
});
describe("isValidRepoSlug", () => {
it("returns true only for valid repo slugs", () => {
expect(isValidRepoSlug("octocat/hello-world")).toBe(true);
expect(isValidRepoSlug("octocat")).toBe(false);
});
});
describe("resolveTaskGithubTracking", () => {
it.each([
[{ enabled: true }, { githubTrackingEnabledByDefault: false }, true, "task"],
[{ enabled: false }, { githubTrackingEnabledByDefault: true }, false, "task"],
[{}, { githubTrackingEnabledByDefault: true }, true, "project"],
[{}, {}, false, "default"],
] as const)(
"resolves enabled precedence",
(taskTracking, projectSettings, expectedEnabled, expectedSource) => {
const resolved = resolveTaskGithubTracking(
{ githubTracking: taskTracking },
projectSettings,
undefined,
);
expect(resolved.enabled).toBe(expectedEnabled);
expect(resolved.source.enabled).toBe(expectedSource);
},
);
it("falls back to global enabled when present", () => {
const resolved = resolveTaskGithubTracking(
{ githubTracking: {} },
{},
{ githubTrackingDefaultEnabledForNewTasks: true },
);
expect(resolved.enabled).toBe(true);
expect(resolved.source.enabled).toBe("global");
});
it("resolves repo from task override first", () => {
const resolved = resolveTaskGithubTracking(
{ githubTracking: { repoOverride: "task/override" } },
{ githubTrackingDefaultRepo: "project/default" },
{ githubTrackingDefaultRepo: "global/default" },
);
expect(resolved.repo).toEqual({ owner: "task", repo: "override" });
expect(resolved.source.repo).toBe("task");
});
it("falls through invalid repo tiers", () => {
const resolved = resolveTaskGithubTracking(
{ githubTracking: { repoOverride: "invalid" } },
{ githubTrackingDefaultRepo: "still invalid" },
{ githubDefaultRepo: "global/default" },
);
expect(resolved.repo).toEqual({ owner: "global", repo: "default" });
expect(resolved.source.repo).toBe("global");
});
it("returns none source when no valid repo exists", () => {
const resolved = resolveTaskGithubTracking(
{ githubTracking: { repoOverride: "invalid" } },
{ githubTrackingDefaultRepo: "also invalid" },
{ githubTrackingDefaultRepo: "" },
);
expect(resolved.repo).toBeNull();
expect(resolved.source.repo).toBe("none");
});
});

View File

@@ -886,7 +886,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(70);
expect(db1.getSchemaVersion()).toBe(71);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,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(70);
expect(db3.getSchemaVersion()).toBe(71);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(70);
expect(db1.getSchemaVersion()).toBe(71);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(70);
expect(db2.getSchemaVersion()).toBe(71);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(70);
expect(db1.getSchemaVersion()).toBe(71);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { TaskGithubTrackedIssue } from "../types.js";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-github-tracking-test-"));
}
describe("TaskStore github tracking", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
const issue: TaskGithubTrackedIssue = {
owner: "octocat",
repo: "hello-world",
number: 42,
url: "https://github.com/octocat/hello-world/issues/42",
createdAt: "2026-05-09T00:00:00.000Z",
};
it("round-trips githubTracking through updateGithubTracking", async () => {
const task = await store.createTask({ description: "Track issue" });
await store.updateGithubTracking(task.id, {
enabled: true,
repoOverride: "octocat/hello-world",
});
const updated = await store.getTask(task.id);
expect(updated?.githubTracking).toEqual({
enabled: true,
repoOverride: "octocat/hello-world",
});
});
it("links and unlinks tracked issue while preserving other tracking fields", async () => {
const task = await store.createTask({ description: "Link issue" });
await store.linkGithubIssue(task.id, issue);
let updated = await store.getTask(task.id);
expect(updated?.githubTracking?.enabled).toBe(true);
expect(updated?.githubTracking?.issue).toEqual(issue);
await store.updateGithubTracking(task.id, {
enabled: false,
repoOverride: "octocat/hello-world",
issue,
});
await store.linkGithubIssue(task.id, issue);
updated = await store.getTask(task.id);
expect(updated?.githubTracking?.enabled).toBe(false);
await store.unlinkGithubIssue(task.id);
updated = await store.getTask(task.id);
expect(updated?.githubTracking?.issue).toBeUndefined();
expect(updated?.githubTracking?.unlinkedAt).toBeTruthy();
expect(updated?.githubTracking?.enabled).toBe(false);
expect(updated?.githubTracking?.repoOverride).toBe("octocat/hello-world");
});
it("does not emit task:updated for idempotent updateGithubTracking writes", async () => {
const task = await store.createTask({ description: "No-op" });
const updatedEvents: string[] = [];
store.on("task:updated", (t) => updatedEvents.push(t.id));
const tracking = { enabled: true, repoOverride: "octocat/hello-world" };
await store.updateGithubTracking(task.id, tracking);
await store.updateGithubTracking(task.id, tracking);
expect(updatedEvents).toEqual([task.id]);
});
it("omits githubTracking in slim list paths", async () => {
const task = await store.createTask({ description: "Slim list" });
await store.updateGithubTracking(task.id, { enabled: true, repoOverride: "octocat/hello-world" });
const tasks = await store.listTasks({ slim: true });
const listed = tasks.find((entry) => entry.id === task.id);
expect(listed?.githubTracking).toBeUndefined();
});
it("preserves githubTracking through archive and restore", async () => {
const task = await store.createTask({ description: "Archive tracking" });
await store.updateGithubTracking(task.id, {
enabled: true,
repoOverride: "octocat/hello-world",
issue,
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const restored = await store.unarchiveTask(task.id);
expect(restored.githubTracking).toEqual({
enabled: true,
repoOverride: "octocat/hello-world",
issue,
});
});
});

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(70);
expect(db.getSchemaVersion()).toBe(71);
const index = db
.prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 70;
const SCHEMA_VERSION = 71;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -206,6 +206,7 @@ CREATE TABLE IF NOT EXISTS tasks (
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
githubTracking TEXT,
sourceIssueProvider TEXT,
sourceIssueRepository TEXT,
sourceIssueExternalIssueId TEXT,
@@ -2960,6 +2961,12 @@ export class Database {
});
}
if (version < 71) {
this.applyMigration(71, () => {
this.addColumnIfMissing("tasks", "githubTracking", "TEXT");
});
}
}
/**

View File

@@ -0,0 +1,99 @@
import type { GlobalSettings, ProjectSettings, Task } from "./types.js";
export interface RepoSlug {
owner: string;
repo: string;
}
export interface ResolvedTaskGithubTracking {
enabled: boolean;
repo: RepoSlug | null;
source: {
enabled: "task" | "project" | "global" | "default";
repo: "task" | "project" | "global" | "none";
};
}
function parseRepoSlugCandidate(input: unknown): RepoSlug | null {
if (typeof input !== "string") return null;
const trimmed = input.trim();
if (!trimmed) return null;
const parts = trimmed.split("/");
if (parts.length !== 2) return null;
const [owner, repo] = parts;
if (!owner || !repo) return null;
if (/\s/.test(owner) || /\s/.test(repo)) return null;
return { owner, repo };
}
export function parseRepoSlug(input: string | undefined | null): RepoSlug | null {
return parseRepoSlugCandidate(input);
}
export function isValidRepoSlug(input: string): boolean {
return parseRepoSlug(input) !== null;
}
export function resolveTaskGithubTracking(
task: Pick<Task, "githubTracking">,
projectSettings?: Pick<ProjectSettings, "githubTrackingEnabledByDefault" | "githubTrackingDefaultRepo"> & {
githubTrackingDefaultEnabledForNewTasks?: boolean;
githubDefaultRepo?: string;
},
globalSettings?: Pick<GlobalSettings, "githubTrackingDefaultRepo"> & {
githubTrackingDefaultEnabledForNewTasks?: boolean;
githubDefaultRepo?: string;
},
): ResolvedTaskGithubTracking {
const taskEnabled = task.githubTracking?.enabled;
const projectEnabled = projectSettings?.githubTrackingEnabledByDefault
?? projectSettings?.githubTrackingDefaultEnabledForNewTasks;
// TODO(FN-3868): remove legacy fallback keys once all callers are migrated.
const globalEnabled = globalSettings?.githubTrackingDefaultEnabledForNewTasks;
let enabled = false;
let enabledSource: ResolvedTaskGithubTracking["source"]["enabled"] = "default";
if (typeof taskEnabled === "boolean") {
enabled = taskEnabled;
enabledSource = "task";
} else if (typeof projectEnabled === "boolean") {
enabled = projectEnabled;
enabledSource = "project";
} else if (typeof globalEnabled === "boolean") {
enabled = globalEnabled;
enabledSource = "global";
} else {
enabled = false;
enabledSource = "default";
}
const taskRepo = parseRepoSlugCandidate(task.githubTracking?.repoOverride);
const projectRepo = parseRepoSlugCandidate(projectSettings?.githubTrackingDefaultRepo ?? projectSettings?.githubDefaultRepo);
const globalRepo = parseRepoSlugCandidate(globalSettings?.githubTrackingDefaultRepo ?? globalSettings?.githubDefaultRepo);
let repo: RepoSlug | null = null;
let repoSource: ResolvedTaskGithubTracking["source"]["repo"] = "none";
if (taskRepo) {
repo = taskRepo;
repoSource = "task";
} else if (projectRepo) {
repo = projectRepo;
repoSource = "project";
} else if (globalRepo) {
repo = globalRepo;
repoSource = "global";
}
return {
enabled,
repo,
source: {
enabled: enabledSource,
repo: repoSource,
},
};
}

View File

@@ -88,7 +88,7 @@ export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPat
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
export {
export {
isGhAvailable,
isGhAuthenticated,
runGh,
@@ -101,6 +101,12 @@ export {
getCurrentRepo,
type GhError,
} from "./gh-cli.js";
export {
parseRepoSlug,
isValidRepoSlug,
resolveTaskGithubTracking,
} from "./github-tracking.js";
export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js";
export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
export { AutomationStore } from "./automation-store.js";

View File

@@ -95,6 +95,7 @@ interface TaskRow {
workflowStepResults: string | null;
prInfo: string | null;
issueInfo: string | null;
githubTracking: string | null;
sourceIssueProvider: string | null;
sourceIssueRepository: string | null;
sourceIssueExternalIssueId: string | null;
@@ -808,6 +809,7 @@ 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),
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
sourceIssue: (() => {
if (
row.sourceIssueProvider === null
@@ -868,6 +870,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewLevel: entry.reviewLevel,
prInfo: slim ? undefined : entry.prInfo,
issueInfo: slim ? undefined : entry.issueInfo,
githubTracking: slim ? undefined : entry.githubTracking,
sourceIssue: slim ? undefined : entry.sourceIssue,
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
@@ -992,6 +995,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
issueInfo: task.issueInfo,
githubTracking: task.githubTracking,
sourceIssue: task.sourceIssue,
attachments: task.attachments,
comments: task.comments,
@@ -1076,7 +1080,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1125,7 +1129,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1168,11 +1172,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, issueInfo,
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -1232,6 +1236,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
workflowStepResults = excluded.workflowStepResults,
prInfo = excluded.prInfo,
issueInfo = excluded.issueInfo,
githubTracking = excluded.githubTracking,
sourceIssueProvider = excluded.sourceIssueProvider,
sourceIssueRepository = excluded.sourceIssueRepository,
sourceIssueExternalIssueId = excluded.sourceIssueExternalIssueId,
@@ -1321,6 +1326,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.githubTracking),
task.sourceIssue?.provider ?? null,
task.sourceIssue?.repository ?? null,
task.sourceIssue?.externalIssueId ?? null,
@@ -2754,6 +2760,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (slim) {
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
task.githubTracking = undefined;
}
if (!slim || task.steps.length > 0) {
@@ -2826,6 +2833,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const task = this.rowToTask(row);
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
task.githubTracking = undefined;
return task;
});
@@ -2937,6 +2945,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (slim) {
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
task.githubTracking = undefined;
}
if (task.steps.length > 0) {
@@ -5970,6 +5979,98 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
async updateGithubTracking(
id: string,
tracking: import("./types.js").TaskGithubTracking | null,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const nextTracking = tracking ?? undefined;
const previousTracking = task.githubTracking;
if (JSON.stringify(previousTracking ?? null) === JSON.stringify(nextTracking ?? null)) {
return task;
}
task.githubTracking = nextTracking;
task.log.push({
timestamp: new Date().toISOString(),
action: tracking?.enabled === false ? "GitHub tracking disabled" : "GitHub tracking enabled",
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async linkGithubIssue(
id: string,
issue: import("./types.js").TaskGithubTrackedIssue,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previous = task.githubTracking ?? {};
const nextTracking: import("./types.js").TaskGithubTracking = {
...previous,
issue,
enabled: previous.enabled ?? true,
};
if (JSON.stringify(previous) === JSON.stringify(nextTracking)) {
return task;
}
task.githubTracking = nextTracking;
task.log.push({
timestamp: new Date().toISOString(),
action: "GitHub issue linked",
outcome: `${issue.owner}/${issue.repo}#${issue.number}`,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async unlinkGithubIssue(id: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previous = task.githubTracking;
const previousIssue = previous?.issue;
if (!previousIssue || !previous) {
return task;
}
task.githubTracking = {
...previous,
issue: undefined,
unlinkedAt: new Date().toISOString(),
};
task.log.push({
timestamp: new Date().toISOString(),
action: "GitHub issue unlinked",
outcome: `${previousIssue.owner}/${previousIssue.repo}#${previousIssue.number}`,
});
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Read historical agent log entries for a task from SQLite.
* Returns entries in chronological order (oldest first).
@@ -6270,6 +6371,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
prInfo: entry.prInfo,
review: entry.review,
issueInfo: entry.issueInfo,
githubTracking: entry.githubTracking,
sourceIssue: entry.sourceIssue,
attachments: entry.attachments,
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],

View File

@@ -576,6 +576,27 @@ export interface IssueInfo {
lastCheckedAt?: string;
}
export interface TaskGithubTrackedIssue {
owner: string;
repo: string;
number: number;
url: string;
nodeId?: string;
createdAt: string;
lastSyncedAt?: string;
}
export interface TaskGithubTracking {
/** Per-task enabled override. When undefined, project/global default applies. */
enabled?: boolean;
/** "owner/repo" override; when undefined, project/global default repo applies. */
repoOverride?: string;
/** Linked GitHub issue. Set after issue creation succeeds. Cleared via unlinkGithubIssue(). */
issue?: TaskGithubTrackedIssue;
/** ISO-8601 of the most recent manual unlink, retained for audit. */
unlinkedAt?: string;
}
/**
* Durable provenance metadata for tasks imported from external issue trackers.
*
@@ -1103,6 +1124,11 @@ export interface Task {
mergeDetails?: MergeDetails;
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
/**
* Per-task tracking metadata for Fusion-emitted GitHub issues.
* Distinct from issueInfo/sourceIssue, which describe imported source issues.
*/
githubTracking?: TaskGithubTracking;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
log: TaskLogEntry[];
@@ -2556,6 +2582,7 @@ export interface ArchivedTaskEntry {
executionMode?: ExecutionMode;
prInfo?: PrInfo;
issueInfo?: IssueInfo;
githubTracking?: TaskGithubTracking;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Attachment metadata (filenames, mime types, etc.) without file content */