diff --git a/packages/eve/src/harness/approval-candidates.test.ts b/packages/eve/src/harness/approval-candidates.test.ts new file mode 100644 index 000000000..498a284a7 --- /dev/null +++ b/packages/eve/src/harness/approval-candidates.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + cancelApprovalRequest, + createApprovalCandidate, + expireApprovalCandidates, + finishApprovalCandidate, + getApprovalAuditState, + markApprovalCandidateAuthorizationRequired, + settleAllowedCandidate, +} from "#harness/approval-candidates.js"; +import type { SessionStateMap } from "#harness/types.js"; + +function responder(principalId: string): SessionAuthContext { + return { + attributes: { workspace: "T1" }, + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId, + principalType: "user", + }; +} + +function create(input: { + readonly candidateId: string; + readonly principalId: string; + readonly requestId?: string; + readonly state?: SessionStateMap; +}) { + return createApprovalCandidate({ + candidateId: input.candidateId, + createdAt: 100, + expiresAt: 700, + requestId: input.requestId ?? "request-1", + responder: responder(input.principalId), + runtimeRevision: "deploy-1", + state: input.state, + }); +} + +describe("approval candidate state", () => { + it("creates durable candidates without persisting broad responder attributes", () => { + const transition = create({ candidateId: "candidate-1", principalId: "U1" }); + + expect(transition.result).toMatchObject({ kind: "created" }); + expect(getApprovalAuditState(transition.state).activeCandidates).toEqual([ + { + candidateId: "candidate-1", + createdAt: 100, + expiresAt: 700, + requestId: "request-1", + responder: { + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId: "U1", + principalType: "user", + }, + runtimeRevision: "deploy-1", + status: "pending", + }, + ]); + }); + + it("silently deduplicates one responder's active candidate", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const duplicate = create({ + candidateId: "candidate-2", + principalId: "U1", + state: first.state, + }); + + expect(duplicate.result).toMatchObject({ + kind: "duplicate", + candidate: { candidateId: "candidate-1" }, + }); + expect(duplicate.state).toBe(first.state); + }); + + it("allows different responders to validate concurrently", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = create({ + candidateId: "candidate-2", + principalId: "U2", + state: first.state, + }); + + expect(second.result).toMatchObject({ kind: "created" }); + expect(getApprovalAuditState(second.state).activeCandidates).toHaveLength(2); + }); + + it("tracks authorization-required state and provider expiry", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const state = markApprovalCandidateAuthorizationRequired({ + candidateId: "candidate-1", + expiresAt: 500, + provider: "GitHub", + state: first.state, + }); + + expect(getApprovalAuditState(state).activeCandidates[0]).toMatchObject({ + expiresAt: 500, + provider: "GitHub", + status: "authorization-required", + }); + }); + + it("persists safe rejection feedback and permits a later retry", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const rejected = finishApprovalCandidate({ + candidateId: "candidate-1", + completedAt: 200, + safeReason: "GitHub write permission is required.", + state: first.state, + status: "rejected", + }); + const retry = create({ + candidateId: "candidate-2", + principalId: "U1", + state: rejected, + }); + + expect(getApprovalAuditState(retry.state).candidateHistory).toEqual([ + expect.objectContaining({ + candidateId: "candidate-1", + safeReason: "GitHub write permission is required.", + status: "rejected", + }), + ]); + expect(retry.result).toMatchObject({ kind: "created" }); + }); + + it("expires only candidates whose deadline has passed", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = createApprovalCandidate({ + candidateId: "candidate-2", + createdAt: 100, + expiresAt: 900, + requestId: "request-1", + responder: responder("U2"), + state: first.state, + }); + const state = expireApprovalCandidates({ now: 800, state: second.state }); + const audit = getApprovalAuditState(state); + + expect(audit.activeCandidates.map((candidate) => candidate.candidateId)).toEqual([ + "candidate-2", + ]); + expect(audit.candidateHistory).toEqual([ + expect.objectContaining({ candidateId: "candidate-1", status: "timed-out" }), + ]); + }); + + it("atomically settles the first allowed candidate and stales competitors", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = create({ + candidateId: "candidate-2", + principalId: "U2", + state: first.state, + }); + const winner = settleAllowedCandidate({ + candidateId: "candidate-2", + settledAt: 300, + state: second.state, + }); + const late = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 400, + state: winner.state, + }); + const audit = getApprovalAuditState(late.state); + + expect(winner.result).toMatchObject({ + kind: "settled", + settlement: { candidateId: "candidate-2", outcome: "allowed" }, + }); + expect(late.result).toMatchObject({ kind: "stale", settlement: { outcome: "allowed" } }); + expect(audit.activeCandidates).toEqual([]); + expect(audit.candidateHistory).toEqual( + expect.arrayContaining([ + expect.objectContaining({ candidateId: "candidate-1", status: "stale" }), + expect.objectContaining({ candidateId: "candidate-2", status: "allowed" }), + ]), + ); + }); + + it("lets Cancel win atomically and stales every Allow candidate", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const cancelled = cancelApprovalRequest({ + actor: responder("U2"), + requestId: "request-1", + settledAt: 250, + state: first.state, + }); + const late = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: cancelled.state, + }); + + expect(cancelled.result).toMatchObject({ + kind: "settled", + settlement: { actor: { principalId: "U2" }, outcome: "cancelled" }, + }); + expect(late.result).toMatchObject({ + kind: "stale", + settlement: { outcome: "cancelled" }, + }); + }); + + it("does not let a candidate start after terminal settlement", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const settled = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: first.state, + }); + const late = create({ + candidateId: "candidate-2", + principalId: "U2", + state: settled.state, + }); + + expect(late.result).toMatchObject({ kind: "stale", settlement: { outcome: "allowed" } }); + }); + + it("keeps unrelated requests active when another request settles", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const unrelated = create({ + candidateId: "candidate-2", + principalId: "U2", + requestId: "request-2", + state: first.state, + }); + const settled = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: unrelated.state, + }); + + expect(getApprovalAuditState(settled.state).activeCandidates).toEqual([ + expect.objectContaining({ candidateId: "candidate-2", requestId: "request-2" }), + ]); + }); +}); diff --git a/packages/eve/src/harness/approval-candidates.ts b/packages/eve/src/harness/approval-candidates.ts new file mode 100644 index 000000000..6a6bddf5d --- /dev/null +++ b/packages/eve/src/harness/approval-candidates.ts @@ -0,0 +1,332 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { SessionStateMap } from "#harness/types.js"; + +const APPROVAL_STATE_KEY = "eve.runtime.hitl.approvalState"; + +export type ApprovalCandidateStatus = + | "pending" + | "authorization-required" + | "allowed" + | "rejected" + | "failed" + | "timed-out" + | "stale"; + +export interface ApprovalCandidateAuditRecord { + readonly candidateId: string; + readonly requestId: string; + readonly responder: ApprovalResponderIdentity; + readonly status: ApprovalCandidateStatus; + readonly createdAt: number; + readonly completedAt?: number; + readonly expiresAt?: number; + readonly provider?: string; + readonly runtimeRevision?: string; + readonly safeReason?: string; +} + +export interface ApprovalResponderIdentity { + readonly authenticator: string; + readonly issuer?: string; + readonly principalId: string; + readonly principalType: string; +} + +export interface ApprovalSettlementAuditRecord { + readonly actor: ApprovalResponderIdentity; + readonly outcome: "allowed" | "cancelled"; + readonly requestId: string; + readonly settledAt: number; + readonly candidateId?: string; +} + +interface ActiveApprovalCandidate { + readonly candidateId: string; + readonly requestId: string; + readonly responder: ApprovalResponderIdentity; + readonly status: "pending" | "authorization-required"; + readonly createdAt: number; + readonly expiresAt: number; + readonly provider?: string; + readonly runtimeRevision?: string; +} + +interface DurableApprovalState { + readonly activeCandidates: Readonly>; + readonly candidateHistory: readonly ApprovalCandidateAuditRecord[]; + readonly settlements: Readonly>; +} + +export type CreateApprovalCandidateResult = + | { readonly kind: "created"; readonly candidate: ActiveApprovalCandidate } + | { readonly kind: "duplicate"; readonly candidate: ActiveApprovalCandidate } + | { readonly kind: "stale"; readonly settlement: ApprovalSettlementAuditRecord }; + +export type SettleApprovalResult = + | { readonly kind: "settled"; readonly settlement: ApprovalSettlementAuditRecord } + | { readonly kind: "stale"; readonly settlement: ApprovalSettlementAuditRecord }; + +export interface ApprovalStateTransition { + readonly result: TResult; + readonly state: SessionStateMap | undefined; +} + +/** Creates or deduplicates one responder's Allow candidate for a pending request. */ +export function createApprovalCandidate(input: { + readonly candidateId: string; + readonly createdAt: number; + readonly expiresAt: number; + readonly requestId: string; + readonly responder: SessionAuthContext; + readonly runtimeRevision?: string; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const approvalState = readApprovalState(input.state); + const settlement = approvalState.settlements[input.requestId]; + if (settlement !== undefined) { + return { result: { kind: "stale", settlement }, state: input.state }; + } + + const responder = projectResponder(input.responder); + const duplicate = Object.values(approvalState.activeCandidates).find( + (candidate) => + candidate.requestId === input.requestId && sameResponder(candidate.responder, responder), + ); + if (duplicate !== undefined) { + return { result: { kind: "duplicate", candidate: duplicate }, state: input.state }; + } + + const candidate: ActiveApprovalCandidate = { + candidateId: input.candidateId, + createdAt: input.createdAt, + expiresAt: input.expiresAt, + requestId: input.requestId, + responder, + runtimeRevision: input.runtimeRevision, + status: "pending", + }; + const next: DurableApprovalState = { + ...approvalState, + activeCandidates: { ...approvalState.activeCandidates, [candidate.candidateId]: candidate }, + }; + return { + result: { candidate, kind: "created" }, + state: writeApprovalState(input.state, next), + }; +} + +/** Marks a candidate as waiting on a private authorization challenge. */ +export function markApprovalCandidateAuthorizationRequired(input: { + readonly candidateId: string; + readonly expiresAt?: number; + readonly provider?: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) return input.state; + const nextCandidate: ActiveApprovalCandidate = { + ...candidate, + expiresAt: input.expiresAt ?? candidate.expiresAt, + provider: input.provider, + status: "authorization-required", + }; + return writeApprovalState(input.state, { + ...approvalState, + activeCandidates: { ...approvalState.activeCandidates, [input.candidateId]: nextCandidate }, + }); +} + +/** Finishes one candidate without settling the shared request. */ +export function finishApprovalCandidate(input: { + readonly candidateId: string; + readonly completedAt: number; + readonly safeReason?: string; + readonly state: SessionStateMap | undefined; + readonly status: Exclude; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) return input.state; + const activeCandidates = { ...approvalState.activeCandidates }; + delete activeCandidates[input.candidateId]; + return writeApprovalState(input.state, { + ...approvalState, + activeCandidates, + candidateHistory: [ + ...approvalState.candidateHistory, + { + ...candidate, + completedAt: input.completedAt, + safeReason: input.safeReason, + status: input.status, + }, + ], + }); +} + +/** Expires active candidates whose deterministic deadline has passed. */ +export function expireApprovalCandidates(input: { + readonly now: number; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + let state = input.state; + const candidates = Object.values(readApprovalState(state).activeCandidates); + for (const candidate of candidates) { + if (candidate.expiresAt > input.now) continue; + state = finishApprovalCandidate({ + candidateId: candidate.candidateId, + completedAt: input.now, + state, + status: "timed-out", + }); + } + return state; +} + +/** Atomically settles an allowed candidate; every losing candidate becomes stale. */ +export function settleAllowedCandidate(input: { + readonly candidateId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) { + const historical = approvalState.candidateHistory.find( + (entry) => entry.candidateId === input.candidateId, + ); + const settlement = historical && approvalState.settlements[historical.requestId]; + if (settlement !== undefined) { + return { result: { kind: "stale", settlement }, state: input.state }; + } + throw new Error(`Unknown approval candidate "${input.candidateId}".`); + } + return settleRequest({ + actor: candidate.responder, + candidateId: candidate.candidateId, + outcome: "allowed", + requestId: candidate.requestId, + settledAt: input.settledAt, + state: input.state, + }); +} + +/** Atomically cancels a pending request using ordinary authenticated flow control. */ +export function cancelApprovalRequest(input: { + readonly actor: SessionAuthContext; + readonly requestId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + return settleRequest({ + actor: projectResponder(input.actor), + outcome: "cancelled", + requestId: input.requestId, + settledAt: input.settledAt, + state: input.state, + }); +} + +/** Returns a copy of the durable candidate/audit state for inspection and replay. */ +export function getApprovalAuditState(state: SessionStateMap | undefined): { + readonly activeCandidates: readonly ActiveApprovalCandidate[]; + readonly candidateHistory: readonly ApprovalCandidateAuditRecord[]; + readonly settlements: readonly ApprovalSettlementAuditRecord[]; +} { + const approvalState = readApprovalState(state); + return { + activeCandidates: Object.values(approvalState.activeCandidates), + candidateHistory: approvalState.candidateHistory, + settlements: Object.values(approvalState.settlements), + }; +} + +function settleRequest(input: { + readonly actor: ApprovalResponderIdentity; + readonly candidateId?: string; + readonly outcome: ApprovalSettlementAuditRecord["outcome"]; + readonly requestId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const approvalState = readApprovalState(input.state); + const existing = approvalState.settlements[input.requestId]; + if (existing !== undefined) { + return { result: { kind: "stale", settlement: existing }, state: input.state }; + } + + const settlement: ApprovalSettlementAuditRecord = { + actor: input.actor, + candidateId: input.candidateId, + outcome: input.outcome, + requestId: input.requestId, + settledAt: input.settledAt, + }; + const activeCandidates: Record = {}; + const candidateHistory = [...approvalState.candidateHistory]; + for (const candidate of Object.values(approvalState.activeCandidates)) { + if (candidate.requestId !== input.requestId) { + activeCandidates[candidate.candidateId] = candidate; + continue; + } + candidateHistory.push({ + ...candidate, + completedAt: input.settledAt, + status: candidate.candidateId === input.candidateId ? "allowed" : "stale", + }); + } + const next: DurableApprovalState = { + activeCandidates, + candidateHistory, + settlements: { ...approvalState.settlements, [input.requestId]: settlement }, + }; + return { + result: { kind: "settled", settlement }, + state: writeApprovalState(input.state, next), + }; +} + +function projectResponder(responder: SessionAuthContext): ApprovalResponderIdentity { + return { + authenticator: responder.authenticator, + issuer: responder.issuer, + principalId: responder.principalId, + principalType: responder.principalType, + }; +} + +function sameResponder(a: ApprovalResponderIdentity, b: ApprovalResponderIdentity): boolean { + return ( + a.authenticator === b.authenticator && + a.issuer === b.issuer && + a.principalId === b.principalId && + a.principalType === b.principalType + ); +} + +function readApprovalState(state: SessionStateMap | undefined): DurableApprovalState { + const value = state?.[APPROVAL_STATE_KEY]; + if (typeof value !== "object" || value === null) { + return { activeCandidates: {}, candidateHistory: [], settlements: {} }; + } + const candidate = value as Partial; + return { + activeCandidates: + typeof candidate.activeCandidates === "object" && candidate.activeCandidates !== null + ? candidate.activeCandidates + : {}, + candidateHistory: Array.isArray(candidate.candidateHistory) ? candidate.candidateHistory : [], + settlements: + typeof candidate.settlements === "object" && candidate.settlements !== null + ? candidate.settlements + : {}, + }; +} + +function writeApprovalState( + state: SessionStateMap | undefined, + approvalState: DurableApprovalState, +): SessionStateMap { + return { ...state, [APPROVAL_STATE_KEY]: approvalState }; +}