Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"test:node-pty-permissions": "node --import tsx --test src/node-pty-postinstall.test.ts",
"test:physical-host-registry": "tsx src/physical-host-registry.test.ts",
"start": "node dist/cli.js serve",
"test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts && tsx src/github-pr-delivery.test.ts",
"test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts && tsx src/github-pr-delivery.test.ts && tsx src/pr-delivery-lifecycle.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:carrier-binding": "tsx src/carrier-binding.test.ts && tsx src/carrier-binding-http.test.ts",
"test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts"
Expand Down
264 changes: 264 additions & 0 deletions src/pr-delivery-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DurableOperationStore, type DurableOperationRecord } from "./durable-operations.js";
import {
GITHUB_PR_DELIVERY_OPERATION_KIND,
GITHUB_PR_DELIVERY_RECEIPT_SCHEMA,
PrDeliveryLifecycleError,
projectPrDeliveryLifecycle,
type PrDeliveryLifecycleState,
} from "./pr-delivery-lifecycle.js";

const KIND = GITHUB_PR_DELIVERY_OPERATION_KIND;

const REQUEST = {
repository: "James3014/devspace",
baseBranch: "main",
expectedBaseSha: "b2e9331f0ed84b5e8902ef322b9e391b8d07c0ed",
candidateBranch: "codex/issue-115-pr-delivery-g0-g3",
expectedCandidateHeadSha: "e4a9897803d647e4ac299973f54995b5cb9433b6",
title: "feat(delivery): bounded GitHub PR delivery",
body: "body",
attemptKey: "g4-attempt-1",
localRepoRoot: "/tmp/devspace",
};

const RECEIPT = {
schema: GITHUB_PR_DELIVERY_RECEIPT_SCHEMA,
repository: "James3014/devspace",
prNumber: 147,
url: "https://github.com/James3014/devspace/pull/147",
headSha: "e4a9897803d647e4ac299973f54995b5cb9433b6",
baseSha: "b2e9331f0ed84b5e8902ef322b9e391b8d07c0ed",
baseBranch: "main",
headBranch: "codex/issue-115-pr-delivery-g0-g3",
title: "feat(delivery): bounded GitHub PR delivery",
matchedExisting: false,
};

function record(overrides: Partial<DurableOperationRecord> = {}): DurableOperationRecord {
return {
operationId: "op_g4_test",
attemptKey: "g4-attempt-1",
requestHash: "hash-g4-1",
kind: KIND,
authorityMode: "OWNER_DIRECT",
scopeRoot: "/tmp/devspace",
status: "started",
retrySafe: false,
request: { ...REQUEST },
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
...overrides,
};
}

const FORBIDDEN_STATES = ["CI_GREEN", "CI_FAILED", "MERGE_READY"] as const;

function assertNeverForbidden(state: PrDeliveryLifecycleState): void {
for (const forbidden of FORBIDDEN_STATES) {
assert.notEqual(state, forbidden, `projection must never emit ${forbidden}`);
}
}

test("G4-01 started projects PR_DELIVERY_IN_FLIGHT without claiming remote create", () => {
const projection = projectPrDeliveryLifecycle(record({ status: "started" }));
assert.equal(projection.state, "PR_DELIVERY_IN_FLIGHT");
assert.equal(projection.pr, undefined);
assertNeverForbidden(projection.state);
});

test("G4-02 outcome_unknown projects PR_DELIVERY_RECONCILE_REQUIRED, never IN_FLIGHT or PREPARED", () => {
const projection = projectPrDeliveryLifecycle(
record({ status: "outcome_unknown", retrySafe: false, errorCode: "RECONCILIATION_REQUIRED" }),
);
assert.equal(projection.state, "PR_DELIVERY_RECONCILE_REQUIRED");
assert.notEqual(projection.state, "PR_DELIVERY_IN_FLIGHT");
assert.notEqual(projection.state, "PR_DELIVERY_PREPARED");
assertNeverForbidden(projection.state);
});

test("G4-03 definitive non-retryable failure projects PR_DELIVERY_BLOCKED, not RECONCILE_REQUIRED", () => {
const projection = projectPrDeliveryLifecycle(
record({ status: "failed", retrySafe: false, errorCode: "REMOTE_IDENTITY_DRIFT" }),
);
assert.equal(projection.state, "PR_DELIVERY_BLOCKED");
assert.equal(projection.gap, "REMOTE_IDENTITY_DRIFT");
assert.equal(projection.retrySafe, false);
assertNeverForbidden(projection.state);
});

test("G4-04 failed retrySafe=true projects PR_DELIVERY_PREPARED preserving error identity", () => {
const projection = projectPrDeliveryLifecycle(
record({ status: "failed", retrySafe: true, errorCode: "CONFIRMED_NO_EFFECT" }),
);
assert.equal(projection.state, "PR_DELIVERY_PREPARED");
assert.equal(projection.errorCode, "CONFIRMED_NO_EFFECT");
assert.equal(projection.retrySafe, true);
assert.equal(projection.operationId, "op_g4_test");
assertNeverForbidden(projection.state);
});

test("G4-05 succeeded with valid exact receipt projects PR_DELIVERED", () => {
const projection = projectPrDeliveryLifecycle(
record({ status: "succeeded", retrySafe: false, receipt: { ...RECEIPT } }),
);
assert.equal(projection.state, "PR_DELIVERED");
assert.deepEqual(projection.pr, {
repository: RECEIPT.repository,
prNumber: RECEIPT.prNumber,
url: RECEIPT.url,
headSha: RECEIPT.headSha,
baseSha: RECEIPT.baseSha,
baseBranch: RECEIPT.baseBranch,
headBranch: RECEIPT.headBranch,
});
assertNeverForbidden(projection.state);
});

test("G4-06 succeeded with missing receipt fails closed to RECONCILE_REQUIRED", () => {
const projection = projectPrDeliveryLifecycle(record({ status: "succeeded", retrySafe: false }));
assert.equal(projection.state, "PR_DELIVERY_RECONCILE_REQUIRED");
assert.equal(projection.gap, "DELIVERY_RECEIPT_MISSING");
assert.equal(projection.pr, undefined);
});

test("G4-07 succeeded with wrong-repository receipt fails closed to RECONCILE_REQUIRED", () => {
const projection = projectPrDeliveryLifecycle(
record({
status: "succeeded",
retrySafe: false,
receipt: { ...RECEIPT, repository: "SomeoneElse/other" },
}),
);
assert.equal(projection.state, "PR_DELIVERY_RECONCILE_REQUIRED");
assert.equal(projection.gap, "DELIVERY_RECEIPT_IDENTITY_MISMATCH");
assert.equal(projection.pr, undefined);
});

test("G4-08 succeeded with wrong head SHA fails closed to RECONCILE_REQUIRED", () => {
const projection = projectPrDeliveryLifecycle(
record({
status: "succeeded",
retrySafe: false,
receipt: { ...RECEIPT, headSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
}),
);
assert.equal(projection.state, "PR_DELIVERY_RECONCILE_REQUIRED");
assert.equal(projection.gap, "DELIVERY_RECEIPT_IDENTITY_MISMATCH");
assert.equal(projection.pr, undefined);
});

test("G4-09 succeeded with wrong branch fails closed to RECONCILE_REQUIRED", () => {
const projection = projectPrDeliveryLifecycle(
record({
status: "succeeded",
retrySafe: false,
receipt: { ...RECEIPT, headBranch: "codex/some-other-branch" },
}),
);
assert.equal(projection.state, "PR_DELIVERY_RECONCILE_REQUIRED");
assert.equal(projection.gap, "DELIVERY_RECEIPT_IDENTITY_MISMATCH");
assert.equal(projection.pr, undefined);
});

test("G4-10 wrong operation kind is rejected, never projected", () => {
assert.throws(
() => projectPrDeliveryLifecycle(record({ kind: "workspace_clone" })),
(error: unknown) =>
error instanceof PrDeliveryLifecycleError && error.code === "WRONG_OPERATION_KIND",
);
});

test("G4-11 projection is pure: frozen input untouched and store read changes nothing", async () => {
const frozen = Object.freeze(record({ status: "succeeded", retrySafe: false, receipt: { ...RECEIPT } }));
const before = JSON.stringify(frozen);
const projection = projectPrDeliveryLifecycle(frozen);
assert.equal(JSON.stringify(frozen), before, "projection must not mutate its input");
assert.equal(projection.state, "PR_DELIVERED");

const stateDir = await mkdtemp(join(tmpdir(), "devspace-g4-lifecycle-"));
try {
const store = new DurableOperationStore(stateDir);
try {
const created = store.createOrReplay({
operationId: "op_g4_readonly",
attemptKey: "g4-readonly-1",
requestHash: "hash-g4-readonly",
kind: KIND,
authorityMode: "OWNER_DIRECT",
scopeRoot: stateDir,
request: { ...REQUEST },
}).record;
const snapshotBefore = JSON.stringify(store.getByOperationId(created.operationId));
const read = store.getByOperationId(created.operationId);
assert.ok(read, "operation must be readable");
assert.equal(read.kind, KIND);
const projected = projectPrDeliveryLifecycle(read);
assert.equal(projected.state, "PR_DELIVERY_IN_FLIGHT");
assert.equal(
JSON.stringify(store.getByOperationId(created.operationId)),
snapshotBefore,
"read plus projection must not change the durable record",
);
} finally {
store.close();
}
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});

test("G4-12 restart-fenced started operation projects PR_DELIVERY_RECONCILE_REQUIRED", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "devspace-g4-restart-"));
try {
const store = new DurableOperationStore(stateDir);
try {
const created = store.createOrReplay({
operationId: "op_g4_restart",
attemptKey: "g4-restart-1",
requestHash: "hash-g4-restart",
kind: KIND,
authorityMode: "OWNER_DIRECT",
scopeRoot: stateDir,
request: { ...REQUEST },
}).record;
assert.equal(projectPrDeliveryLifecycle(created).state, "PR_DELIVERY_IN_FLIGHT");
const fenced = store.markInterruptedUnknown();
assert.equal(fenced, 1, "exactly the started delivery operation must be fenced");
const after = store.getByOperationId(created.operationId);
assert.ok(after);
assert.equal(after.status, "outcome_unknown");
assert.equal(projectPrDeliveryLifecycle(after).state, "PR_DELIVERY_RECONCILE_REQUIRED");
} finally {
store.close();
}
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});

test("G4-13/14/15 projection never emits CI_GREEN, CI_FAILED, or MERGE_READY", () => {
const fixtures: DurableOperationRecord[] = [
record({ status: "started" }),
record({ status: "outcome_unknown", errorCode: "RECONCILIATION_REQUIRED" }),
record({ status: "failed", retrySafe: false, errorCode: "REMOTE_IDENTITY_DRIFT" }),
record({ status: "failed", retrySafe: true, errorCode: "CONFIRMED_NO_EFFECT" }),
record({ status: "succeeded", receipt: { ...RECEIPT } }),
record({ status: "succeeded" }),
];
for (const fixture of fixtures) {
assertNeverForbidden(projectPrDeliveryLifecycle(fixture).state);
}
const states: PrDeliveryLifecycleState[] = [
"PR_DELIVERY_PREPARED",
"PR_DELIVERY_IN_FLIGHT",
"PR_DELIVERED",
"PR_DELIVERY_RECONCILE_REQUIRED",
"PR_DELIVERY_BLOCKED",
];
assert.equal(states.length, 5, "G4 state union must stay bounded without CI/merge states");
});
Loading
Loading