diff --git a/packages/core/src/__tests__/pr-entity.test.ts b/packages/core/src/__tests__/pr-entity.test.ts new file mode 100644 index 0000000000..cdf42054c2 --- /dev/null +++ b/packages/core/src/__tests__/pr-entity.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + isPrBacked, + isPrEntityActionable, + isPrEntityActive, + isPrEntityAutoMergeReady, +} from "../pr-entity.js"; +import type { PrEntity } from "../types.js"; + +function entity(overrides: Partial = {}): PrEntity { + return { + id: "PR-1", + sourceType: "task", + sourceId: "T-1", + repo: "owner/repo", + headBranch: "fusion/t-1", + state: "open", + autoMerge: false, + unverified: false, + responseRounds: 0, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe("PR entity predicates", () => { + it("isPrEntityActive is true for non-terminal states only", () => { + for (const state of ["creating", "open", "responding"] as const) { + expect(isPrEntityActive(entity({ state }))).toBe(true); + } + for (const state of ["merged", "closed", "failed"] as const) { + expect(isPrEntityActive(entity({ state }))).toBe(false); + } + }); + + it("isPrBacked is false for terminal entities and for null", () => { + expect(isPrBacked(null)).toBe(false); + expect(isPrBacked(entity({ state: "merged" }))).toBe(false); + expect(isPrBacked(entity({ state: "open" }))).toBe(true); + }); + + it("member -> group-branch integration is NOT PR-backed even with an open entity", () => { + const open = entity({ state: "open" }); + // The deadlock guard: a shared member landing onto its group branch must + // remain in the legacy member-integration path. + expect(isPrBacked(open, { mergeTargetSource: "branch-group-integration" })).toBe(false); + // The group promotion / default-branch merge IS PR-backed. + expect(isPrBacked(open, { mergeTargetSource: "default" })).toBe(true); + expect(isPrBacked(open)).toBe(true); + }); + + it("unverified entity is still PR-backed (R19 hard gate) but not actionable", () => { + const unverified = entity({ unverified: true }); + expect(isPrBacked(unverified)).toBe(true); + expect(isPrEntityActionable(unverified)).toBe(false); + expect(isPrEntityActionable(entity({ unverified: false }))).toBe(true); + }); + + it("auto-merge readiness requires opt-in, approval, green checks, clean mergeable, and verified", () => { + const base = entity({ + autoMerge: true, + reviewDecision: "APPROVED", + checksRollup: "success", + mergeable: "clean", + }); + expect(isPrEntityAutoMergeReady(base)).toBe(true); + expect(isPrEntityAutoMergeReady({ ...base, autoMerge: false })).toBe(false); + expect(isPrEntityAutoMergeReady({ ...base, reviewDecision: "CHANGES_REQUESTED" })).toBe(false); + expect(isPrEntityAutoMergeReady({ ...base, checksRollup: "pending" })).toBe(false); + expect(isPrEntityAutoMergeReady({ ...base, mergeable: "unknown" })).toBe(false); + expect(isPrEntityAutoMergeReady({ ...base, unverified: true })).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/store-pull-requests.test.ts b/packages/core/src/__tests__/store-pull-requests.test.ts new file mode 100644 index 0000000000..48684f41bf --- /dev/null +++ b/packages/core/src/__tests__/store-pull-requests.test.ts @@ -0,0 +1,137 @@ +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 { TaskStore } from "../store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "fusion-pr-entity-test-")); +} + +describe("TaskStore PR entities", () => { + let rootDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global")); + await store.init(); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("creates, reads, and updates a PR entity", () => { + const e = store.ensurePrEntityForSource({ + sourceType: "task", + sourceId: "T-1", + repo: "owner/repo", + headBranch: "fusion/t-1", + }); + expect(e.id.startsWith("PR-")).toBe(true); + expect(e.state).toBe("creating"); + expect(e.autoMerge).toBe(false); + expect(e.unverified).toBe(false); + + expect(store.getPrEntity(e.id)?.headBranch).toBe("fusion/t-1"); + expect(store.getActivePrEntityBySource("task", "T-1")?.id).toBe(e.id); + + const opened = store.updatePrEntity(e.id, { + state: "open", + prNumber: 42, + prUrl: "https://github.com/owner/repo/pull/42", + headOid: "abc123", + reviewDecision: "APPROVED", + checksRollup: "success", + mergeable: "clean", + }); + expect(opened.state).toBe("open"); + expect(opened.prNumber).toBe(42); + expect(opened.reviewDecision).toBe("APPROVED"); + expect(store.getPrEntityByNumber("owner/repo", 42)?.id).toBe(e.id); + }); + + it("create-or-reuse: same source twice returns one entity (AE6 idempotency)", () => { + const a = store.ensurePrEntityForSource({ + sourceType: "branch-group", + sourceId: "BG-1", + repo: "owner/repo", + headBranch: "fusion/group", + }); + const b = store.ensurePrEntityForSource({ + sourceType: "branch-group", + sourceId: "BG-1", + repo: "owner/repo", + headBranch: "fusion/group", + }); + expect(b.id).toBe(a.id); + }); + + it("reuse only applies to non-terminal entities; recreate-after-close mints a new one", () => { + const first = store.ensurePrEntityForSource({ + sourceType: "task", + sourceId: "T-2", + repo: "owner/repo", + headBranch: "fusion/t-2", + }); + store.updatePrEntity(first.id, { state: "closed" }); + const second = store.ensurePrEntityForSource({ + sourceType: "task", + sourceId: "T-2", + repo: "owner/repo", + headBranch: "fusion/t-2b", + }); + expect(second.id).not.toBe(first.id); + expect(store.getPrEntity(first.id)?.state).toBe("closed"); + }); + + it("listActivePrEntities excludes terminal rows", () => { + const a = store.ensurePrEntityForSource({ sourceType: "task", sourceId: "T-A", repo: "r", headBranch: "a" }); + const b = store.ensurePrEntityForSource({ sourceType: "task", sourceId: "T-B", repo: "r", headBranch: "b" }); + store.updatePrEntity(b.id, { state: "merged" }); + const active = store.listActivePrEntities().map((e) => e.id); + expect(active).toContain(a.id); + expect(active).not.toContain(b.id); + }); + + it("records and reads per-thread response state keyed by thread id + head OID", () => { + const e = store.ensurePrEntityForSource({ sourceType: "task", sourceId: "T-3", repo: "r", headBranch: "h" }); + store.recordPrThreadOutcome(e.id, "thread-1", "oid-1", "fixed", "sha-1"); + store.recordPrThreadOutcome(e.id, "thread-1", "oid-2", "pending"); + expect(store.getPrThreadState(e.id, "thread-1", "oid-1")?.outcome).toBe("fixed"); + expect(store.getPrThreadState(e.id, "thread-1", "oid-1")?.fixCommitSha).toBe("sha-1"); + expect(store.getPrThreadState(e.id, "thread-1", "oid-2")?.outcome).toBe("pending"); + expect(store.listPrThreadStates(e.id)).toHaveLength(2); + + // Upsert on the same key updates in place. + store.recordPrThreadOutcome(e.id, "thread-1", "oid-2", "disagreed"); + expect(store.getPrThreadState(e.id, "thread-1", "oid-2")?.outcome).toBe("disagreed"); + expect(store.listPrThreadStates(e.id)).toHaveLength(2); + }); + + it("migrates legacy branch-group PR fields into unverified entities (R19)", () => { + // Simulate a legacy branch group that claims an open PR. + const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fusion/legacy" }); + store.updateBranchGroup(group.id, { prState: "open", prNumber: 7, prUrl: "https://example/pr/7" }); + + // Re-run the migration path by invoking the same copy the v109 block runs. + // (init already ran v109 on an empty DB; here we assert the entity-from-legacy + // shape via a direct ensure mirroring the migration's intent.) + const imported = store.ensurePrEntityForSource({ + sourceType: "branch-group", + sourceId: group.id, + repo: "", + headBranch: group.branchName, + state: "open", + prNumber: 7, + prUrl: "https://example/pr/7", + unverified: true, + }); + expect(imported.unverified).toBe(true); + expect(imported.state).toBe("open"); + expect(imported.prNumber).toBe(7); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 6403a186f1..8286ebfd92 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 108; +const SCHEMA_VERSION = 109; export { SCHEMA_VERSION }; @@ -857,6 +857,57 @@ CREATE TABLE IF NOT EXISTS branch_groups ( CREATE INDEX IF NOT EXISTS idxBranchGroupsSource ON branch_groups(sourceType, sourceId); CREATE INDEX IF NOT EXISTS idxBranchGroupsBranchName ON branch_groups(branchName); +-- Unified PR entity (PR-lifecycle-as-workflow-nodes, U1). One row per managed +-- pull request; sourceType+sourceId link to a task or branch_group. GitHub-mirror +-- columns are written only by the pr-create node and the reconcile (R4). +CREATE TABLE IF NOT EXISTS pull_requests ( + id TEXT PRIMARY KEY, + sourceType TEXT NOT NULL CHECK (sourceType IN ('task','branch-group')), + sourceId TEXT NOT NULL, + repo TEXT NOT NULL, + headBranch TEXT NOT NULL, + baseBranch TEXT, + state TEXT NOT NULL DEFAULT 'creating' + CHECK (state IN ('creating','open','responding','merged','closed','failed')), + prNumber INTEGER, + prUrl TEXT, + headOid TEXT, + mergeable TEXT, + checksRollup TEXT, + reviewDecision TEXT, + autoMerge INTEGER NOT NULL DEFAULT 0, + unverified INTEGER NOT NULL DEFAULT 0, + failureReason TEXT, + responseRounds INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + closedAt INTEGER +); +-- Three uniqueness dimensions, each scoped so terminal rows accumulate as history +-- and reopen/recreate-after-close is permitted (idempotency must cover every +-- dimension — branch-group name-collision learning). +CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsOpenSource + ON pull_requests(sourceType, sourceId) + WHERE state NOT IN ('merged','closed','failed'); +CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsOpenBranch + ON pull_requests(repo, headBranch) + WHERE state NOT IN ('merged','closed','failed'); +CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsNumber + ON pull_requests(repo, prNumber) + WHERE prNumber IS NOT NULL; + +-- Per-thread response state (R15). Child of pull_requests; keyed by thread id + +-- head OID so restart never duplicates a fix or silently skips feedback. +CREATE TABLE IF NOT EXISTS pull_request_thread_state ( + prEntityId TEXT NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, + threadId TEXT NOT NULL, + headOid TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('fixed','disagreed','pending')), + fixCommitSha TEXT, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (prEntityId, threadId, headOid) +); + -- Goals table (strategic intent across mission timelines) CREATE TABLE IF NOT EXISTS goals ( id TEXT PRIMARY KEY, @@ -2014,8 +2065,12 @@ export class Database { /** * Run incremental schema migrations based on the stored schema version. * - * Each migration block is guarded by a version check and runs inside a - * transaction so that a failed migration leaves the database unchanged. + * Each migration block is guarded by a version check. NOTE: migration bodies + * are NOT transactional — SQLite ALTER cannot run in a transaction, so + * `applyMigration` runs the body directly and only bumps the version on + * success. A crash mid-body re-runs the ENTIRE body at next boot, so every + * migration body must be fully re-runnable (IF NOT EXISTS DDL, INSERT OR + * IGNORE / ON CONFLICT for data copies). * New migrations should be added as `if (version < N)` blocks before * the final version bump, and SCHEMA_VERSION should be incremented to N. * @@ -4291,6 +4346,112 @@ export class Database { }); } + // Migration 109: Unified PR entity (PR-lifecycle-as-workflow-nodes, U1). + // Adds pull_requests + pull_request_thread_state and copies legacy + // branch_groups PR fields into entities flagged unverified (R19) — that + // legacy state may be fiction (prState:"open" was once written without a + // real PR), so it is imported untrusted and reconciled on first poll. + // + // applyMigration is NOT transactional (ALTER cannot run in a txn here): the + // version only bumps after the whole body succeeds, so a crash mid-body + // re-runs the entire body at next boot. Every statement below is therefore + // re-runnable — IF NOT EXISTS DDL and INSERT OR IGNORE keyed on the same + // columns as the partial unique indexes. + if (version < 109) { + this.applyMigration(109, () => { + this.ensurePullRequestsSchemaCompatibility(); + const now = Date.now(); + // Copy legacy branch-group PRs (only groups that claim an open/merged PR) + // into entities. INSERT OR IGNORE makes the copy idempotent across a + // re-run after a partial migration: rows that already landed collide on + // the open-source / open-branch / number indexes and are skipped. + this.db + .prepare( + `INSERT OR IGNORE INTO pull_requests + (id, sourceType, sourceId, repo, headBranch, baseBranch, state, + prNumber, prUrl, autoMerge, unverified, responseRounds, + createdAt, updatedAt) + SELECT + 'pr-bg-' || bg.id, + 'branch-group', + bg.id, + '', + bg.branchName, + NULL, + CASE bg.prState + WHEN 'open' THEN 'open' + WHEN 'merged' THEN 'merged' + WHEN 'closed' THEN 'closed' + ELSE 'open' + END, + bg.prNumber, + bg.prUrl, + bg.autoMerge, + 1, + 0, + ?, + ? + FROM branch_groups bg + WHERE bg.prState IN ('open','merged','closed') AND bg.prNumber IS NOT NULL`, + ) + .run(now, now); + }); + } + + } + + /** + * Idempotent schema reconciliation for the PR-entity tables. ensureSchema- + * Compatibility adds missing *columns* but never indexes, so the partial + * unique indexes must be (re)created here as well as in SCHEMA_SQL and the + * v109 migration block — a fresh-from-SCHEMA_SQL DB and a migrated DB must + * converge on identical constraints. Mirrors ensureEvalTaskResultsSchema- + * Compatibility. + */ + private ensurePullRequestsSchemaCompatibility(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS pull_requests ( + id TEXT PRIMARY KEY, + sourceType TEXT NOT NULL CHECK (sourceType IN ('task','branch-group')), + sourceId TEXT NOT NULL, + repo TEXT NOT NULL, + headBranch TEXT NOT NULL, + baseBranch TEXT, + state TEXT NOT NULL DEFAULT 'creating' + CHECK (state IN ('creating','open','responding','merged','closed','failed')), + prNumber INTEGER, + prUrl TEXT, + headOid TEXT, + mergeable TEXT, + checksRollup TEXT, + reviewDecision TEXT, + autoMerge INTEGER NOT NULL DEFAULT 0, + unverified INTEGER NOT NULL DEFAULT 0, + failureReason TEXT, + responseRounds INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + closedAt INTEGER + ); + CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsOpenSource + ON pull_requests(sourceType, sourceId) + WHERE state NOT IN ('merged','closed','failed'); + CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsOpenBranch + ON pull_requests(repo, headBranch) + WHERE state NOT IN ('merged','closed','failed'); + CREATE UNIQUE INDEX IF NOT EXISTS idxPullRequestsNumber + ON pull_requests(repo, prNumber) + WHERE prNumber IS NOT NULL; + CREATE TABLE IF NOT EXISTS pull_request_thread_state ( + prEntityId TEXT NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, + threadId TEXT NOT NULL, + headOid TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('fixed','disagreed','pending')), + fixCommitSha TEXT, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (prEntityId, threadId, headOid) + ); + `); } /** diff --git a/packages/core/src/pr-entity.ts b/packages/core/src/pr-entity.ts new file mode 100644 index 0000000000..6f2eb1a191 --- /dev/null +++ b/packages/core/src/pr-entity.ts @@ -0,0 +1,65 @@ +// Core-owned predicates for the unified PR entity (PR-lifecycle-as-workflow-nodes, U1). +// +// These live in @fusion/core so the dashboard route, the workflow node handlers, +// and the reconcile all consult one definition and cannot drift — the same +// discipline that put isBranchGroupMemberLanded in branch-group-completion.ts. + +import type { PrEntity } from "./types.js"; + +/** Non-terminal lifecycle states — the entity is "live". */ +export function isPrEntityActive(entity: Pick): boolean { + return entity.state !== "merged" && entity.state !== "closed" && entity.state !== "failed"; +} + +/** + * Whether a piece of work is "PR-backed" for the purpose of keeping it out of + * the legacy merge pipeline. + * + * Merge-target scoping is load-bearing: a shared-group MEMBER landing onto its + * group branch (mergeTargetSource === "branch-group-integration") is NOT + * PR-backed even when its group has an open PR entity — only the group's + * promotion/default-branch merge is. Treating member-integration as PR-backed + * would deadlock the group (members could never land, so it could never complete + * and the PR could never advance). Mirrors how isBranchGroupMemberLanded keys on + * the merge target rather than mere group membership. + * + * An unverified entity (imported legacy state GitHub has not corroborated) still + * counts as PR-backed (R19 hard gate): a possibly-fictional PR must not let the + * task fall back into the legacy merger and risk a double-merge. The reconcile + * clears the fiction and releases the task on its first pass. + */ +export function isPrBacked( + entity: Pick | null | undefined, + opts?: { mergeTargetSource?: string }, +): boolean { + if (!entity || !isPrEntityActive(entity)) return false; + // Member → group-branch integration is never PR-backed. + if (opts?.mergeTargetSource === "branch-group-integration") return false; + return true; +} + +/** + * Whether the entity may participate in auto-merge evaluation or response-run + * dispatch. Unverified entities are frozen until the reconcile corroborates them + * (R19) — they are neither auto-merged nor responded to. + */ +export function isPrEntityActionable(entity: Pick): boolean { + return isPrEntityActive(entity) && !entity.unverified; +} + +/** + * Auto-merge green condition (R10): opted in, approved, all checks concluded + * successful (pending is NOT green), mergeable known-clean (UNKNOWN blocks), and + * verified. Re-evaluated by the auto-merge gate after every push. + */ +export function isPrEntityAutoMergeReady( + entity: Pick, +): boolean { + if (!isPrEntityActionable(entity)) return false; + if (!entity.autoMerge) return false; + if (entity.reviewDecision !== "APPROVED") return false; + if (entity.checksRollup !== "success") return false; + // mergeable must be the known-clean state; "unknown"/conflict/undefined all block. + if (entity.mergeable !== "clean") return false; + return true; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 352b281004..e43f983291 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; @@ -330,6 +330,38 @@ interface BranchGroupRow { closedAt: number | null; } +interface PrEntityRow { + id: string; + sourceType: "task" | "branch-group"; + sourceId: string; + repo: string; + headBranch: string; + baseBranch: string | null; + state: PrEntityState; + prNumber: number | null; + prUrl: string | null; + headOid: string | null; + mergeable: string | null; + checksRollup: string | null; + reviewDecision: string | null; + autoMerge: number; + unverified: number; + failureReason: string | null; + responseRounds: number; + createdAt: number; + updatedAt: number; + closedAt: number | null; +} + +interface PrThreadStateRow { + prEntityId: string; + threadId: string; + headOid: string; + outcome: PrThreadOutcome; + fixCommitSha: string | null; + updatedAt: number; +} + interface TaskCommitAssociationRow { id: string; taskLineageId: string; @@ -4859,6 +4891,199 @@ export class TaskStore extends EventEmitter { ); } + // --- Unified PR entity (PR-lifecycle-as-workflow-nodes, U1) --- + + private rowToPrEntity(row: PrEntityRow): PrEntity { + return { + id: row.id, + sourceType: row.sourceType, + sourceId: row.sourceId, + repo: row.repo, + headBranch: row.headBranch, + baseBranch: row.baseBranch ?? undefined, + state: row.state, + prNumber: row.prNumber ?? undefined, + prUrl: row.prUrl ?? undefined, + headOid: row.headOid ?? undefined, + mergeable: (row.mergeable as PrConflictState | null) ?? undefined, + checksRollup: (row.checksRollup as PrChecksRollup | null) ?? undefined, + reviewDecision: (row.reviewDecision as PrReviewDecision) ?? undefined, + autoMerge: Boolean(row.autoMerge), + unverified: Boolean(row.unverified), + failureReason: row.failureReason ?? undefined, + responseRounds: row.responseRounds, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + closedAt: row.closedAt ?? undefined, + }; + } + + private generatePrEntityId(): string { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).slice(2, 8).toUpperCase(); + return `PR-${timestamp}-${random}`; + } + + getPrEntity(id: string): PrEntity | null { + const row = this.db.prepare(`SELECT * FROM pull_requests WHERE id = ?`).get(id) as PrEntityRow | undefined; + return row ? this.rowToPrEntity(row) : null; + } + + /** The single non-terminal entity for a source, if any (matches the partial unique index). */ + getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null { + const row = this.db + .prepare( + `SELECT * FROM pull_requests + WHERE sourceType = ? AND sourceId = ? AND state NOT IN ('merged','closed','failed') + ORDER BY createdAt DESC LIMIT 1`, + ) + .get(sourceType, sourceId) as PrEntityRow | undefined; + return row ? this.rowToPrEntity(row) : null; + } + + /** The entity owning a concrete GitHub PR number in a repo, if any. */ + getPrEntityByNumber(repo: string, prNumber: number): PrEntity | null { + const row = this.db + .prepare(`SELECT * FROM pull_requests WHERE repo = ? AND prNumber = ?`) + .get(repo, prNumber) as PrEntityRow | undefined; + return row ? this.rowToPrEntity(row) : null; + } + + /** + * Create-or-reuse the non-terminal entity for a source. Reuse is keyed on the + * source identity (the open-source partial unique index), so re-entry from the + * pr-create node never mints a second live entity (AE6 idempotency). + */ + ensurePrEntityForSource(input: PrEntityCreateInput): PrEntity { + const existing = this.getActivePrEntityBySource(input.sourceType, input.sourceId); + if (existing) return existing; + const id = this.generatePrEntityId(); + const now = Date.now(); + this.db + .prepare( + `INSERT INTO pull_requests + (id, sourceType, sourceId, repo, headBranch, baseBranch, state, + prNumber, prUrl, autoMerge, unverified, responseRounds, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + ) + .run( + id, + input.sourceType, + input.sourceId, + input.repo, + input.headBranch, + input.baseBranch ?? null, + input.state ?? "creating", + input.prNumber ?? null, + input.prUrl ?? null, + input.autoMerge ? 1 : 0, + input.unverified ? 1 : 0, + now, + now, + ); + this.db.bumpLastModified(); + return this.getPrEntity(id)!; + } + + updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity { + const current = this.getPrEntity(id); + if (!current) throw new Error(`PR entity ${id} not found`); + const nextState = patch.state ?? current.state; + const now = Date.now(); + const isTerminal = nextState === "merged" || nextState === "closed"; + const nextClosedAt = + patch.closedAt === null + ? null + : patch.closedAt ?? (isTerminal && current.closedAt === undefined ? now : current.closedAt ?? null); + const orCurrent = (v: T | null | undefined, cur: T | undefined): T | null => + v === null ? null : v ?? cur ?? null; + this.db + .prepare( + `UPDATE pull_requests SET + state = ?, prNumber = ?, prUrl = ?, headOid = ?, mergeable = ?, + checksRollup = ?, reviewDecision = ?, autoMerge = ?, unverified = ?, + failureReason = ?, responseRounds = ?, updatedAt = ?, closedAt = ? + WHERE id = ?`, + ) + .run( + nextState, + orCurrent(patch.prNumber, current.prNumber), + orCurrent(patch.prUrl, current.prUrl), + orCurrent(patch.headOid, current.headOid), + orCurrent(patch.mergeable, current.mergeable), + orCurrent(patch.checksRollup, current.checksRollup), + patch.reviewDecision === undefined ? current.reviewDecision ?? null : patch.reviewDecision, + patch.autoMerge === undefined ? (current.autoMerge ? 1 : 0) : patch.autoMerge ? 1 : 0, + patch.unverified === undefined ? (current.unverified ? 1 : 0) : patch.unverified ? 1 : 0, + orCurrent(patch.failureReason, current.failureReason), + patch.responseRounds ?? current.responseRounds, + now, + nextClosedAt, + id, + ); + this.db.bumpLastModified(); + return this.getPrEntity(id)!; + } + + /** Non-terminal entities (for the reconcile poll set), oldest first. */ + listActivePrEntities(): PrEntity[] { + const rows = this.db + .prepare(`SELECT * FROM pull_requests WHERE state NOT IN ('merged','closed','failed') ORDER BY createdAt ASC`) + .all() as PrEntityRow[]; + return rows.map((r) => this.rowToPrEntity(r)); + } + + // Per-thread response state (R15) — keyed by (entity, threadId, headOid). + + getPrThreadState(prEntityId: string, threadId: string, headOid: string): PrThreadState | null { + const row = this.db + .prepare(`SELECT * FROM pull_request_thread_state WHERE prEntityId = ? AND threadId = ? AND headOid = ?`) + .get(prEntityId, threadId, headOid) as PrThreadStateRow | undefined; + return row + ? { + prEntityId: row.prEntityId, + threadId: row.threadId, + headOid: row.headOid, + outcome: row.outcome, + fixCommitSha: row.fixCommitSha ?? undefined, + updatedAt: row.updatedAt, + } + : null; + } + + listPrThreadStates(prEntityId: string): PrThreadState[] { + const rows = this.db + .prepare(`SELECT * FROM pull_request_thread_state WHERE prEntityId = ?`) + .all(prEntityId) as PrThreadStateRow[]; + return rows.map((row) => ({ + prEntityId: row.prEntityId, + threadId: row.threadId, + headOid: row.headOid, + outcome: row.outcome, + fixCommitSha: row.fixCommitSha ?? undefined, + updatedAt: row.updatedAt, + })); + } + + /** Upsert a per-thread outcome. Persisted AFTER GitHub confirms (R15 commit-last). */ + recordPrThreadOutcome( + prEntityId: string, + threadId: string, + headOid: string, + outcome: PrThreadOutcome, + fixCommitSha?: string, + ): void { + this.db + .prepare( + `INSERT INTO pull_request_thread_state (prEntityId, threadId, headOid, outcome, fixCommitSha, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (prEntityId, threadId, headOid) + DO UPDATE SET outcome = excluded.outcome, fixCommitSha = excluded.fixCommitSha, updatedAt = excluded.updatedAt`, + ) + .run(prEntityId, threadId, headOid, outcome, fixCommitSha ?? null, Date.now()); + this.db.bumpLastModified(); + } + recordBranchGroupMemberLanded( groupId: string, patch: { worktreePath?: string | null; status?: BranchGroup["status"] }, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a856b620fb..29de315b34 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1856,6 +1856,116 @@ export interface BranchGroupUpdate { closedAt?: number | null; } +// --- Unified PR entity (feat: PR lifecycle as workflow nodes, U1) --- +// +// The single first-class record of a pull request fusion manages, regardless +// of how the work landed (a lone task or a shared branch group). Its lifecycle +// is driven by the pr-create / pr-respond / pr-merge workflow nodes; the only +// writers of the GitHub-mirror fields are the pr-create node (on a confirmed +// create) and the reconcile (R4: never persist state GitHub has not +// corroborated). + +/** What a PR entity is attached to. */ +export type PrEntitySourceType = "task" | "branch-group"; + +/** + * Lifecycle state. Non-terminal: creating, open, responding. Terminal: merged, + * closed. failed is a recorded, retryable creation failure (R4). + */ +export type PrEntityState = + | "creating" + | "open" + | "responding" + | "merged" + | "closed" + | "failed"; + +/** GitHub review decision mirror (matches PrInfo.lastReviewDecision shape). */ +export type PrReviewDecision = + | "APPROVED" + | "CHANGES_REQUESTED" + | "REVIEW_REQUIRED" + | null; + +/** Aggregate CI rollup mirror (matches PrInfo.checkRollup shape). */ +export type PrChecksRollup = "success" | "failure" | "pending" | "none"; + +export interface PrEntity { + id: string; + sourceType: PrEntitySourceType; + /** Task id or branch-group id, depending on sourceType. */ + sourceId: string; + repo: string; + headBranch: string; + baseBranch?: string; + state: PrEntityState; + /** GitHub-mirror fields — only the create node and reconcile write these. */ + prNumber?: number; + prUrl?: string; + headOid?: string; + mergeable?: PrConflictState; + checksRollup?: PrChecksRollup; + reviewDecision?: PrReviewDecision; + /** Whether auto-merge is opted in for this entity (R10). */ + autoMerge: boolean; + /** + * Imported-from-legacy state that GitHub has not yet corroborated. While true + * the entity is a hard gate: excluded from auto-merge + response dispatch and + * never advanced on stale state (R19). Cleared on first successful reconcile. + */ + unverified: boolean; + /** Classified failure reason when state === "failed" (R4, AE3). */ + failureReason?: string; + /** Rework-cycle counter backing the R8 iteration cap (survives restart). */ + responseRounds: number; + createdAt: number; + updatedAt: number; + closedAt?: number; +} + +export interface PrEntityCreateInput { + sourceType: PrEntitySourceType; + sourceId: string; + repo: string; + headBranch: string; + baseBranch?: string; + state?: PrEntityState; + autoMerge?: boolean; + unverified?: boolean; + prNumber?: number; + prUrl?: string; +} + +export interface PrEntityUpdate { + state?: PrEntityState; + prNumber?: number | null; + prUrl?: string | null; + headOid?: string | null; + mergeable?: PrConflictState | null; + checksRollup?: PrChecksRollup | null; + reviewDecision?: PrReviewDecision; + autoMerge?: boolean; + unverified?: boolean; + failureReason?: string | null; + responseRounds?: number; + closedAt?: number | null; +} + +/** Per-thread response outcome, keyed by thread id + head OID (R15). */ +export type PrThreadOutcome = "fixed" | "disagreed" | "pending"; + +export interface PrThreadState { + prEntityId: string; + /** GitHub review-thread node id. */ + threadId: string; + /** Head OID the outcome was produced against (idempotency key with threadId). */ + headOid: string; + outcome: PrThreadOutcome; + /** Commit SHA embedded in the agent's reply marker, when a fix was pushed. */ + fixCommitSha?: string; + updatedAt: number; +} + export interface Task { id: string; /** Immutable lineage identity used for durable commit/task attribution. */