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:
74
packages/core/src/__tests__/pr-entity.test.ts
Normal file
74
packages/core/src/__tests__/pr-entity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
packages/core/src/__tests__/store-pull-requests.test.ts
Normal file
137
packages/core/src/__tests__/store-pull-requests.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
packages/core/src/pr-entity.ts
Normal file
65
packages/core/src/pr-entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user