import { PersistentStore } from '../state/persistent-store.js'; import type { PermissionPromptDecision, PermissionPromptRequest, PermissionRequestHandler } from '../permissions/prompt.js'; import { type RememberTier } from '../permissions/approval-rules.js'; import type { ControlPlaneSurfaceMessage } from './types.js'; import { type RaisedApproval } from './approval-broker-raise.js'; export type SharedApprovalStatus = 'pending' | 'claimed' | 'approved' | 'denied' | 'cancelled' | 'expired'; export interface SharedApprovalAuditRecord { readonly id: string; readonly action: 'created' | 'claimed' | 'approved' | 'denied' | 'cancelled' | 'expired' | 'updated'; readonly actor: string; readonly actorSurface?: string | undefined; readonly createdAt: number; readonly note?: string | undefined; } export interface SharedApprovalRecord { readonly id: string; readonly callId: string; readonly sessionId?: string | undefined; readonly routeId?: string | undefined; readonly status: SharedApprovalStatus; readonly request: PermissionPromptRequest; readonly createdAt: number; readonly updatedAt: number; readonly claimedBy?: string | undefined; readonly claimedAt?: number | undefined; readonly resolvedAt?: number | undefined; readonly resolvedBy?: string | undefined; readonly decision?: PermissionPromptDecision | undefined; /** * When this ask stops waiting, for asks that were given a timeout. * * PERSISTED deliberately. The expiry timer lives in `pendingResolvers`, which * is in-memory and rebuilt empty on start, so before this field existed a * restart left a pending approval with no timer, no deadline and no way to * ever resolve, it sat 'pending' forever. Recording the deadline on the * record is what lets `start()` re-arm it, or expire it immediately when the * deadline passed while the process was down. See `rearmRestoredTimers`. */ readonly expiresAt?: number | undefined; /** * The REAL session an ACCEPTED ask spawned, when acceptance starts one * (e.g. the CI fix-session a "fix this?" offer starts), always an id * session attach/resume resolves, never a scheduling handle. Stamped at * the moment the session exists via {@link ApprovalBroker.stampFixSession} * and published as a record update, so the surface that accepted, * attached right now, gets an in-process handle to jump to the session. * Never present on denied records; mutually exclusive with fixSessionError. */ readonly fixSessionId?: string | undefined; /** * The honest failure when an accepted ask's spawn did NOT produce an * attachable session, recorded instead of a dead id. Mutually exclusive * with fixSessionId; never present on denied records. */ readonly fixSessionError?: string | undefined; readonly metadata: Record; readonly audit: readonly SharedApprovalAuditRecord[]; } interface SharedApprovalStoreSnapshot extends Record { readonly approvals: readonly SharedApprovalRecord[]; } export interface RequestSharedApprovalInput { readonly request: PermissionPromptRequest; readonly sessionId?: string | undefined; readonly routeId?: string | undefined; readonly metadata?: Record | undefined; readonly localPrompt?: PermissionRequestHandler | undefined; readonly timeoutMs?: number | undefined; /** * Which surface the local prompt belongs to, for the audit trail. * * Was hardcoded to 'tui'/'tui-local' regardless of caller, so every product * that answers at its own terminal, the agent, and now the payment * capability's approval prompts, recorded a decision made somewhere it was * not. That is a lie in exactly the record you consult to find out who * approved a purchase. Defaults preserve the old values for callers that do * not say, because the TUI was the only caller when they were written. */ readonly localPromptSurface?: string | undefined; readonly localPromptActor?: string | undefined; } type ApprovalListener = (approval: SharedApprovalRecord) => void; type ApprovalPublisher = { publishEvent(event: string, payload: unknown): void; publishSurfaceMessage(message: Omit): void; }; export declare class ApprovalBroker { private readonly store; private readonly approvals; private readonly pendingResolvers; private readonly listeners; /** Whole-store writes run one at a time, in call order. See StoreWriteQueue. */ private readonly writes; private publisher; private loaded; constructor(options: { readonly store?: PersistentStore | undefined; readonly storePath?: string | undefined; }); subscribe(listener: ApprovalListener): () => void; setPublisher(publisher: ApprovalPublisher | null): void; start(): Promise; /** * Re-arm expiry for approvals restored from disk. * * Without this, a restart orphaned every timed approval: the timer lived only * in `pendingResolvers`, which is rebuilt empty, so a record reloaded as * 'pending' had nothing left that would ever resolve it and sat pending * forever. For a tool-permission ask that is a stale row; for a payment * approval it is money in limbo. * * A deadline that passed while the process was down expires IMMEDIATELY rather * than being extended. Silence for the full window is silence whether or not * we were running to hear it, and for an approval silence means denied, the * direction that cannot spend money nobody agreed to. * * There are no local resolvers to call for a restored record (the awaiting * caller died with the previous process), so this settles the RECORD, which * is what every surface reads. */ private rearmRestoredTimers; listApprovals(limit?: number): SharedApprovalRecord[]; getApproval(approvalId: string): SharedApprovalRecord | null; /** * Raise an ask and hand back BOTH the record it produced and the decision * still to come. * * Two callers want different halves of the same act. The in-process * permission path (`requestApproval`, just below) wants the decision: * it is awaiting a person. The wire path (`approvals.raise`, * routes/approvals-raise.ts) wants the RECORD, immediately, an HTTP request * must not stay open across someone's attention span, and the id returned * here is what ties that caller to the `approval-update` stream where the * decision actually lands. The body lives in approval-broker-raise.ts. * * A `localPrompt` is honoured exactly as before: it runs only for a record * this call actually created (a coalesced ask attaches to a prompt that is * already on screen), after the record is persisted and published, and its * answer resolves the record through the same path a wire decision takes. */ raiseApproval(input: RequestSharedApprovalInput): Promise; /** Raise an ask and wait for the answer, the in-process permission path. */ requestApproval(input: RequestSharedApprovalInput): Promise; claimApproval(approvalId: string, actor: string, actorSurface?: string, note?: string): Promise; /** * Stamp the outcome of an ACCEPTED ask's spawn (e.g. the CI fix-session an * accepted "fix this?" offer started) onto the resolved approval record for * `callId`, and publish the update so already-attached subscribers see the * record change live. This is deliberately the broker seam, not the receipts * queue: receipts deliver at the NEXT attach, but the accepting surface is * attached right now and needs an in-process handle to open the session. * * The success outcome carries the REAL spawned session id (attach/resume- * resolvable, never a scheduling handle); the failure outcome records the * honest error instead of a dead id. Returns the updated record, or null * when no APPROVED record with that callId exists, a denied offer is * never stamped. */ stampFixSession(callId: string, outcome: { readonly sessionId: string; } | { readonly error: string; }): Promise; resolveApproval(approvalId: string, input: { readonly approved: boolean; readonly remember?: boolean | undefined; readonly modifiedArgs?: Record | undefined; /** * Optional per-hunk selection (edit-tool approvals only). When present and * the approval is being APPROVED, the broker computes the modified args * server-side from THIS approval's own `request.args.edits`, so every * surface (TUI, webui) produces identical results. It supersedes any * `modifiedArgs` passed by the caller. Omitting it is the back-compat * whole-request approve-all path. An out-of-range index or a non-edit * approval throws an INVALID_ARGUMENT (400) error, mirroring the closed- * session guard's honest-4xx shape. */ readonly selectedHunks?: readonly number[] | undefined; /** * How far this decision reaches (see PermissionPromptDecision). A * generalizing tier also SWEEPS queued asks the remembered decision * covers, one answer resolves them all. */ readonly rememberTier?: RememberTier | undefined; /** Optional user free-text; on deny it rides the structured result. */ readonly reason?: string | undefined; readonly actor: string; readonly actorSurface?: string | undefined; readonly note?: string | undefined; }): Promise; /** Resolve every waiter attached to an approval (coalesced asks share one record). */ private resolvePending; recordRemoteUpdate(approvalId: string, input: { readonly actor: string; readonly actorSurface?: string | undefined; readonly note?: string | undefined; readonly metadata?: Record | undefined; }): Promise; cancelApproval(approvalId: string, actor: string, actorSurface?: string, note?: string): Promise; private expireApproval; /** * Drop the oldest terminal (approved/denied/cancelled/expired) approvals once * they exceed MAX_APPROVAL_RECORDS, bounding both the in-memory Map and the * persisted snapshot. Pending/claimed approvals are excluded and never evicted, * so their awaiting callers and pendingResolvers are never orphaned. */ private pruneTerminalApprovals; /** * Replace the store file with the approvals as they stand at THIS call, * after every write already queued has finished. * * The snapshot is still taken here, synchronously, exactly as it always was. * What changed is that the write no longer races: `PersistentStore.persist` * is atomic but unordered, so two of these in flight at once finished * whenever their renames happened to land, and the one that started first * could finish last and put its older view of the store back on disk. The * queue is what makes "started first" mean "finished first". * * Ordering is sufficient because callers mutate the map and then persist, so * each snapshot is at least as new as the one queued before it and the last * one to land is the most recent state. Deferring the snapshot to write time * would also work, but it would let a write serialise records belonging to * callers that had not finished yet, including a create still deciding * whether it can commit, and that is a wider door than this defect needs. */ private persist; private publish; } export {}; //# sourceMappingURL=approval-broker.d.ts.map