feat(pr): PR entity foundation — schema v109, store CRUD, predicates (U1)

Adds the unified PR entity: pull_requests + pull_request_thread_state
tables (schema v109, re-runnable migration with three partial unique
indexes and a compat helper), PrEntity/PrThreadState types, store CRUD
(create-or-reuse, update, per-thread outcome upsert), and core-owned
predicates (isPrBacked merge-target-scoped, unverified hard gate,
auto-merge readiness). Migrates legacy branch_groups PR fields into
unverified entities (R19). Fixes the misleading 'transactional migration'
docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 17:26:07 -07:00
parent 4d61374ee7
commit 4ecf3e86fa
6 changed files with 776 additions and 4 deletions

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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)
);
`);
}
/**

View File

@@ -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<PrEntity, "state">): 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<PrEntity, "state"> | 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<PrEntity, "state" | "unverified">): 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<PrEntity, "state" | "unverified" | "autoMerge" | "reviewDecision" | "checksRollup" | "mergeable">,
): 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;
}

View File

@@ -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<TaskStoreEvents> {
);
}
// --- 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 = <T>(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"] },

View File

@@ -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. */