feat(FN-4967): merge fusion/fn-4967
This commit is contained in:
@@ -717,7 +717,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -767,7 +767,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -796,7 +796,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -831,7 +831,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -291,7 +291,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -319,7 +319,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1384,7 +1384,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1409,11 +1409,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1448,7 +1448,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1489,7 +1489,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1561,7 +1561,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1801,7 +1801,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1875,7 +1875,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
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" }]);
|
||||
@@ -1899,7 +1899,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
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" }]);
|
||||
@@ -2003,7 +2003,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2222,7 +2222,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(85);
|
||||
expect(localDb.getSchemaVersion()).toBe(86);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2533,7 +2533,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2687,7 +2687,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(85);
|
||||
expect(migrated.getSchemaVersion()).toBe(86);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2733,7 +2733,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(85);
|
||||
expect(migrated.getSchemaVersion()).toBe(86);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2760,7 +2760,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(85);
|
||||
expect(fresh.getSchemaVersion()).toBe(86);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
@@ -1000,7 +1000,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(85);
|
||||
expect(db1.getSchemaVersion()).toBe(86);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,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(85);
|
||||
expect(db3.getSchemaVersion()).toBe(86);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(85);
|
||||
expect(db1.getSchemaVersion()).toBe(86);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(85);
|
||||
expect(db2.getSchemaVersion()).toBe(86);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,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(85);
|
||||
expect(db1.getSchemaVersion()).toBe(86);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2801,7 +2801,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("85");
|
||||
expect(version.value).toBe("86");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("85");
|
||||
expect(version.value).toBe("86");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
|
||||
expect(projectVersion.value).toBe("85");
|
||||
expect(projectVersion.value).toBe("86");
|
||||
expect(centralVersion.value).toBe("13");
|
||||
} finally {
|
||||
projectDb.close();
|
||||
|
||||
88
packages/core/src/__tests__/store-pr-infos.test.ts
Normal file
88
packages/core/src/__tests__/store-pr-infos.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import type { PrInfo } from "../types.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("TaskStore prInfos", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
let store: ReturnType<typeof harness.store>;
|
||||
|
||||
const pr = (number: number, patch: Partial<PrInfo> = {}): PrInfo => ({
|
||||
url: `https://github.com/acme/repo/pull/${number}`,
|
||||
number,
|
||||
status: "open",
|
||||
title: `PR ${number}`,
|
||||
headBranch: `feature/${number}`,
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
...patch,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
store = harness.store();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("round-trips prInfos through sqlite row + rehydrate", async () => {
|
||||
const task = await harness.createTestTask();
|
||||
await store.addPrInfo(task.id, pr(11));
|
||||
await store.addPrInfo(task.id, pr(22));
|
||||
|
||||
const db = (store as any).db;
|
||||
const row = db.prepare("SELECT prInfos FROM tasks WHERE id = ?").get(task.id) as { prInfos: string | null };
|
||||
expect(row.prInfos).toContain('"number":22');
|
||||
expect(row.prInfos).toContain('"number":11');
|
||||
|
||||
const reopened = await store.getTask(task.id);
|
||||
expect(reopened.prInfos?.map((entry) => entry.number)).toEqual([22, 11]);
|
||||
expect(reopened.prInfo?.number).toBe(22);
|
||||
});
|
||||
|
||||
it("materializes legacy prInfo into prInfos without writing back on read", async () => {
|
||||
const task = await harness.createTestTask();
|
||||
await store.updatePrInfo(task.id, pr(33));
|
||||
const db = (store as any).db;
|
||||
db.prepare("UPDATE tasks SET prInfos = NULL WHERE id = ?").run(task.id);
|
||||
|
||||
const migrated = await store.getTask(task.id);
|
||||
expect(migrated.prInfos?.map((entry) => entry.number)).toEqual([33]);
|
||||
|
||||
const row = db.prepare("SELECT prInfos FROM tasks WHERE id = ?").get(task.id) as { prInfos: string | null };
|
||||
expect(row.prInfos).toBeNull();
|
||||
});
|
||||
|
||||
it("supports add/update/remove by PR number", async () => {
|
||||
const task = await harness.createTestTask();
|
||||
await store.addPrInfo(task.id, pr(1));
|
||||
await store.addPrInfo(task.id, pr(2));
|
||||
await store.updatePrInfoByNumber(task.id, 1, { status: "merged" });
|
||||
const updated = await store.removePrInfoByNumber(task.id, 2);
|
||||
|
||||
expect(updated?.prInfos).toHaveLength(1);
|
||||
expect(updated?.prInfos?.[0].number).toBe(1);
|
||||
expect(updated?.prInfos?.[0].status).toBe("merged");
|
||||
});
|
||||
|
||||
it("keeps primary mirror on most recently checked open PR", async () => {
|
||||
const task = await harness.createTestTask();
|
||||
await store.addPrInfo(task.id, pr(1, { lastCheckedAt: "2026-05-17T10:00:00.000Z" }));
|
||||
await store.addPrInfo(task.id, pr(2, { lastCheckedAt: "2026-05-17T11:00:00.000Z" }));
|
||||
await store.updatePrInfoByNumber(task.id, 1, { lastCheckedAt: "2026-05-17T12:00:00.000Z" });
|
||||
|
||||
const current = await store.getTask(task.id);
|
||||
expect(current.prInfo?.number).toBe(1);
|
||||
});
|
||||
|
||||
it("legacy updatePrInfo(null) clears prInfo and prInfos", async () => {
|
||||
const task = await harness.createTestTask();
|
||||
await store.addPrInfo(task.id, pr(1));
|
||||
await store.addPrInfo(task.id, pr(2));
|
||||
const cleared = await store.updatePrInfo(task.id, null);
|
||||
|
||||
expect(cleared.prInfo).toBeUndefined();
|
||||
expect(cleared.prInfos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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(85);
|
||||
expect(db.getSchemaVersion()).toBe(86);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
25
packages/core/src/__tests__/task-helpers.test.ts
Normal file
25
packages/core/src/__tests__/task-helpers.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPrimaryPrInfo } from "../task-helpers.js";
|
||||
|
||||
describe("getPrimaryPrInfo", () => {
|
||||
it("returns prInfo when only legacy field is set", () => {
|
||||
const prInfo = { number: 1 } as any;
|
||||
expect(getPrimaryPrInfo({ prInfo })).toBe(prInfo);
|
||||
});
|
||||
|
||||
it("returns first prInfos entry when only prInfos is set", () => {
|
||||
const first = { number: 2 } as any;
|
||||
const second = { number: 3 } as any;
|
||||
expect(getPrimaryPrInfo({ prInfos: [first, second] })).toBe(first);
|
||||
});
|
||||
|
||||
it("prefers prInfos[0] when both fields are set", () => {
|
||||
const prInfo = { number: 1 } as any;
|
||||
const first = { number: 2 } as any;
|
||||
expect(getPrimaryPrInfo({ prInfo, prInfos: [first] })).toBe(first);
|
||||
});
|
||||
|
||||
it("returns undefined when neither field is set", () => {
|
||||
expect(getPrimaryPrInfo({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -120,7 +120,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 85;
|
||||
const SCHEMA_VERSION = 86;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -251,6 +251,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
reviewState TEXT,
|
||||
workflowStepResults TEXT DEFAULT '[]',
|
||||
prInfo TEXT,
|
||||
prInfos TEXT,
|
||||
issueInfo TEXT,
|
||||
githubTracking TEXT,
|
||||
sourceIssueProvider TEXT,
|
||||
@@ -3407,6 +3408,12 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 86) {
|
||||
this.applyMigration(86, () => {
|
||||
this.addColumnIfMissing("tasks", "prInfos", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -166,6 +166,7 @@ export {
|
||||
hasTitleIdDrift,
|
||||
normalizeTitleForTaskId,
|
||||
} from "./task-title-id-drift.js";
|
||||
export { getPrimaryPrInfo } from "./task-helpers.js";
|
||||
export type {
|
||||
TaskIdIntegrityAnomaly,
|
||||
TaskIdIntegrityAnomalyKind,
|
||||
|
||||
@@ -129,6 +129,7 @@ interface TaskRow {
|
||||
reviewState: string | null;
|
||||
workflowStepResults: string | null;
|
||||
prInfo: string | null;
|
||||
prInfos: string | null;
|
||||
issueInfo: string | null;
|
||||
githubTracking: string | null;
|
||||
sourceIssueProvider: string | null;
|
||||
@@ -1215,6 +1216,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
reviewState: normalizeTaskReviewState(fromJson<import("./types.js").TaskReviewState>(row.reviewState) ?? undefined),
|
||||
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),
|
||||
prInfos: (() => {
|
||||
const multi = fromJson<import("./types.js").PrInfo[]>(row.prInfos);
|
||||
if (multi && multi.length > 0) return multi;
|
||||
const single = fromJson<import("./types.js").PrInfo>(row.prInfo);
|
||||
return single ? [single] : undefined;
|
||||
})(),
|
||||
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
|
||||
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
|
||||
sourceIssue: (() => {
|
||||
@@ -1285,6 +1292,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
size: entry.size,
|
||||
reviewLevel: entry.reviewLevel,
|
||||
prInfo: slim ? undefined : entry.prInfo,
|
||||
prInfos: slim ? undefined : entry.prInfos,
|
||||
issueInfo: slim ? undefined : entry.issueInfo,
|
||||
githubTracking: entry.githubTracking,
|
||||
sourceIssue: slim ? undefined : entry.sourceIssue,
|
||||
@@ -1414,6 +1422,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
size: task.size,
|
||||
reviewLevel: task.reviewLevel,
|
||||
prInfo: task.prInfo,
|
||||
prInfos: task.prInfos,
|
||||
issueInfo: task.issueInfo,
|
||||
githubTracking: task.githubTracking,
|
||||
sourceIssue: task.sourceIssue,
|
||||
@@ -1503,7 +1512,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
||||
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
@@ -1552,7 +1561,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "attachments", "steeringComments",
|
||||
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
@@ -1655,6 +1664,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJsonNullable(task.reviewState),
|
||||
toJson(task.workflowStepResults || []),
|
||||
toJsonNullable(task.prInfo),
|
||||
toJson(task.prInfos || []),
|
||||
toJsonNullable(task.issueInfo),
|
||||
toJsonNullable(task.githubTracking),
|
||||
task.sourceIssue?.provider ?? null,
|
||||
@@ -1710,7 +1720,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
|
||||
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||
) VALUES (${placeholders})
|
||||
@@ -1737,7 +1747,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
|
||||
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||
) VALUES (${placeholders})
|
||||
@@ -1813,6 +1823,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
reviewState = excluded.reviewState,
|
||||
workflowStepResults = excluded.workflowStepResults,
|
||||
prInfo = excluded.prInfo,
|
||||
prInfos = excluded.prInfos,
|
||||
issueInfo = excluded.issueInfo,
|
||||
githubTracking = excluded.githubTracking,
|
||||
sourceIssueProvider = excluded.sourceIssueProvider,
|
||||
@@ -7315,13 +7326,39 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
private getTaskPrInfos(task: Task): import("./types.js").PrInfo[] {
|
||||
return [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : []))];
|
||||
}
|
||||
|
||||
private resolvePrimaryPrInfo(prInfos: import("./types.js").PrInfo[]): import("./types.js").PrInfo | undefined {
|
||||
// Primary selection rule: prefer the most-recently-updated open PR; if none are open,
|
||||
// fall back to the first linked PR for stable back-compat rendering.
|
||||
const openPrs = prInfos.filter((entry) => entry.status === "open");
|
||||
if (openPrs.length === 0) return prInfos[0];
|
||||
const sorted = [...openPrs].sort((a, b) => {
|
||||
const aTs = Date.parse(a.lastCheckedAt ?? a.lastCommentAt ?? "");
|
||||
const bTs = Date.parse(b.lastCheckedAt ?? b.lastCommentAt ?? "");
|
||||
if (Number.isFinite(aTs) && Number.isFinite(bTs)) return bTs - aTs;
|
||||
if (Number.isFinite(aTs)) return -1;
|
||||
if (Number.isFinite(bTs)) return 1;
|
||||
return 0;
|
||||
});
|
||||
return sorted[0] ?? prInfos[0];
|
||||
}
|
||||
|
||||
private upsertPrInfoByNumber(prInfos: import("./types.js").PrInfo[], prInfo: import("./types.js").PrInfo): import("./types.js").PrInfo[] {
|
||||
const idx = prInfos.findIndex((entry) => entry.number === prInfo.number);
|
||||
if (idx >= 0) {
|
||||
const next = [...prInfos];
|
||||
next[idx] = { ...next[idx], ...prInfo };
|
||||
return next;
|
||||
}
|
||||
return [prInfo, ...prInfos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or clear PR information for a task.
|
||||
* Updates task.json atomically and emits `task:updated` event.
|
||||
*
|
||||
* @param id - The task ID
|
||||
* @param prInfo - The PR info to set, or null to clear
|
||||
* @returns The updated task
|
||||
*/
|
||||
async updatePrInfo(
|
||||
id: string,
|
||||
@@ -7343,41 +7380,89 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
previous?.lastCommentAt !== prInfo?.lastCommentAt;
|
||||
const linkChanged = previous?.number !== prInfo?.number || previous?.url !== prInfo?.url;
|
||||
|
||||
let prInfos = this.getTaskPrInfos(task);
|
||||
if (prInfo) {
|
||||
task.prInfo = prInfo;
|
||||
prInfos = this.upsertPrInfoByNumber(prInfos, prInfo);
|
||||
if (!previous || linkChanged) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "PR linked",
|
||||
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
|
||||
});
|
||||
task.log.push({ timestamp: new Date().toISOString(), action: "PR linked", outcome: `PR #${prInfo.number}: ${prInfo.url}` });
|
||||
} else if (badgeChanged) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "PR updated",
|
||||
outcome: `PR #${prInfo.number} badge metadata refreshed`,
|
||||
});
|
||||
task.log.push({ timestamp: new Date().toISOString(), action: "PR updated", outcome: `PR #${prInfo.number} badge metadata refreshed` });
|
||||
}
|
||||
} else {
|
||||
task.prInfo = undefined;
|
||||
if (previous?.number) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "PR unlinked",
|
||||
outcome: `PR #${previous.number} removed`,
|
||||
});
|
||||
if (previous?.number !== undefined) {
|
||||
task.log.push({ timestamp: new Date().toISOString(), action: "PR unlinked", outcome: `PR #${previous.number} removed` });
|
||||
}
|
||||
prInfos = [];
|
||||
}
|
||||
|
||||
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
|
||||
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
if (badgeChanged || linkChanged || !prInfo) this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
if (badgeChanged) {
|
||||
this.emit("task:updated", task);
|
||||
async addPrInfo(id: string, prInfo: import("./types.js").PrInfo): Promise<Task | undefined> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
let prInfos = this.getTaskPrInfos(task);
|
||||
const existingIndex = prInfos.findIndex((entry) => entry.number === prInfo.number);
|
||||
if (existingIndex >= 0) {
|
||||
prInfos[existingIndex] = { ...prInfos[existingIndex], ...prInfo };
|
||||
} else {
|
||||
prInfos = [prInfo, ...prInfos];
|
||||
}
|
||||
task.prInfos = prInfos;
|
||||
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
||||
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 updatePrInfoByNumber(id: string, number: number, patch: Partial<import("./types.js").PrInfo>): Promise<Task | undefined> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const prInfos = this.getTaskPrInfos(task);
|
||||
const index = prInfos.findIndex((entry) => entry.number === number);
|
||||
if (index < 0) {
|
||||
storeLog.warn(`[store] updatePrInfoByNumber: PR #${number} not found for ${id}`);
|
||||
return task;
|
||||
}
|
||||
prInfos[index] = { ...prInfos[index], ...patch };
|
||||
task.prInfos = prInfos;
|
||||
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
||||
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 removePrInfoByNumber(id: string, number: number): Promise<Task | undefined> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const prInfos = this.getTaskPrInfos(task).filter((entry) => entry.number !== number);
|
||||
if ((task.prInfos ?? []).length === prInfos.length && task.prInfo?.number !== number) {
|
||||
storeLog.warn(`[store] removePrInfoByNumber: PR #${number} not found for ${id}`);
|
||||
return task;
|
||||
}
|
||||
task.prInfos = prInfos.length > 0 ? prInfos : undefined;
|
||||
task.prInfo = this.resolvePrimaryPrInfo(prInfos);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
5
packages/core/src/task-helpers.ts
Normal file
5
packages/core/src/task-helpers.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PrInfo, Task } from "./types.js";
|
||||
|
||||
export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
return task.prInfos?.[0] ?? task.prInfo;
|
||||
}
|
||||
@@ -831,6 +831,7 @@ export interface BatchStatusRequest {
|
||||
export interface BatchStatusEntry {
|
||||
issueInfo?: IssueInfo;
|
||||
prInfo?: PrInfo;
|
||||
prInfos?: PrInfo[];
|
||||
stale: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1543,6 +1544,8 @@ export interface Task {
|
||||
reviewState?: TaskReviewState;
|
||||
/** PR information for tasks linked to GitHub pull requests */
|
||||
prInfo?: PrInfo;
|
||||
/** Canonical list of linked PRs; prInfo mirrors the primary PR for back-compat. */
|
||||
prInfos?: PrInfo[];
|
||||
mergeDetails?: MergeDetails;
|
||||
/** Issue information for tasks imported from GitHub issues */
|
||||
issueInfo?: IssueInfo;
|
||||
@@ -3499,6 +3502,7 @@ export interface ArchivedTaskEntry {
|
||||
* - "fast": Expedited execution with minimal overhead for simple tasks */
|
||||
executionMode?: ExecutionMode;
|
||||
prInfo?: PrInfo;
|
||||
prInfos?: PrInfo[];
|
||||
issueInfo?: IssueInfo;
|
||||
githubTracking?: TaskGithubTracking;
|
||||
/** Durable source provenance for the originating external issue. */
|
||||
|
||||
Reference in New Issue
Block a user