src / policy / approvalStore.ts
src / policy / approvalStore.ts
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import type { MutationKind } from "./permissions";
import { renderPlanMarkdown, type PlanDocument } from "./planDocument";
export type ApprovalKind = MutationKind | "plan";
export type ApprovalStatus =
| "pending"
| "approved"
| "denied"
| "consumed"
| "completed"
| "superseded"
| "expired";
export interface ApprovalReference {
transactionId?: string;
planId?: string;
runId?: string;
jobId?: string;
}
export interface ApprovalResume {
tool: string;
arguments: Record<string, unknown>;
}
export interface ApprovalDecision {
by: "chat_command";
reason?: string;
at: string;
}
export interface ApprovalRecord {
version: 1;
id: string;
kind: ApprovalKind;
status: ApprovalStatus;
operationHash: string;
title: string;
description: string;
destructive: boolean;
reference: ApprovalReference;
resume?: ApprovalResume;
plan?: PlanDocument;
createdAt: string;
updatedAt: string;
decidedAt?: string;
consumedAt?: string;
completedAt?: string;
expiresAt?: string;
decision?: ApprovalDecision;
}
export interface StageInput {
kind: MutationKind;
operationHash: string;
title: string;
description: string;
destructive: boolean;
reference: ApprovalReference;
resume: ApprovalResume;
}
interface ApprovalState {
version: 1;
records: ApprovalRecord[];
}
export interface ApprovalSnapshot {
/** The most recent pending plan, if any. */
pendingPlan?: ApprovalRecord;
/** The most recent approved plan, if any. */
activePlan?: ApprovalRecord;
/** Pending non-plan records, newest first. */
pendingApprovals: ApprovalRecord[];
}
/**
* The one derivation of "what is the model waiting on", shared by the tools
* (`workspace_inspect` capabilities/overview) and the prompt preprocessor's
* mode hint. `records` must be newest-first, as `list()` returns them.
*/
export function summarizeApprovals(records: ApprovalRecord[]): ApprovalSnapshot {
const pendingPlan = records.find((record) => record.kind === "plan" && record.status === "pending");
const activePlan = records.find((record) => record.kind === "plan" && record.status === "approved");
return {
...(pendingPlan ? { pendingPlan } : {}),
...(activePlan ? { activePlan } : {}),
pendingApprovals: records.filter((record) => record.kind !== "plan" && record.status === "pending"),
};
}
const approvalLocks = new Map<string, Promise<void>>();
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const MAX_RECORDS = 300;
const LIVE_STATUSES: ApprovalStatus[] = ["pending", "approved"];
const MATCHABLE_STATUSES: ApprovalStatus[] = ["pending", "approved", "denied"];
/**
* Records in these states expire 24 h after they were created — a denial is
* sticky for the identical operation only until then, and an approved plan is
* an unlock only for as long as the conversation that earned it plausibly
* lasts. Approvals are workspace-scoped, not chat-scoped, and there is no chat
* id to scope them with; a plan approved on Monday and never completed used to
* make Plan mode behave as Auto in a new chat on Wednesday, because
* `activePlan()` simply returns the newest approved plan. A time bound is the
* closest thing to a session bound the store can express. `completed` and
* `superseded` are terminal and never expire.
*/
const EXPIRABLE_STATUSES: ApprovalStatus[] = ["pending", "approved", "denied"];
function validateId(id: string): void {
if (!/^(approval|plan)_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid approval id: ${id}`);
}
}
export class ApprovalStore {
private readonly approvalsRelative: string;
private readonly plansRelative: string;
private readonly stateRelative: string;
public constructor(
private readonly storage: InternalStorage,
private readonly journal: Journal,
) {
this.approvalsRelative = storage.relative("approvals");
this.plansRelative = storage.relative("plans");
this.stateRelative = storage.relative("approvals", "state.json");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.approvalsRelative);
}
public stateRelativePath(): string {
return this.stateRelative;
}
public planPath(id: string): string {
validateId(id);
return `${this.plansRelative}/${id}/plan.md`;
}
/**
* Reads are load-only: they take no lock and never write `state.json`.
* Expiry is applied in memory by `load()`, so a stale record reads as
* `expired` immediately; the next real mutation persists the same result.
* Plans expire on the same rule as everything else — see EXPIRABLE_STATUSES.
*/
public async list(filter: { status?: ApprovalStatus; kind?: ApprovalKind } = {}): Promise<ApprovalRecord[]> {
const state = await this.load();
return state.records
.filter((record) => (filter.status ? record.status === filter.status : true))
.filter((record) => (filter.kind ? record.kind === filter.kind : true))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
public async get(id: string): Promise<ApprovalRecord> {
validateId(id);
const state = await this.load();
const record = state.records.find((candidate) => candidate.id === id);
if (!record) throw new AgenticError("NOT_FOUND", `Approval not found: ${id}`);
return record;
}
/** One read of the store, summarised into the pending/approved plan and pending approvals. */
public async snapshot(): Promise<ApprovalSnapshot> {
return summarizeApprovals(await this.list());
}
public async find(operationHash: string): Promise<ApprovalRecord | undefined> {
const records = await this.list();
return records.find(
(record) =>
record.operationHash === operationHash && MATCHABLE_STATUSES.includes(record.status),
);
}
public async mostRecentPending(): Promise<ApprovalRecord | undefined> {
return (await this.list({ status: "pending" }))[0];
}
public async stage(input: StageInput): Promise<ApprovalRecord> {
return await this.mutate(async (state) => {
const existing = state.records.find(
(record) => record.operationHash === input.operationHash && record.status === "pending",
);
if (existing) return existing;
const now = new Date();
const record: ApprovalRecord = {
version: 1,
id: createId("approval"),
kind: input.kind,
status: "pending",
operationHash: input.operationHash,
title: input.title.slice(0, 300),
description: input.description.slice(0, 4000),
destructive: input.destructive,
reference: { ...input.reference },
resume: input.resume,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(),
};
state.records.push(record);
await this.journal.record({
category: "approval",
action: "staged",
summary: `${record.id} staged: ${record.title}`,
details: { kind: record.kind, operationHash: record.operationHash },
});
return record;
});
}
public async approve(id: string | undefined, decision: { reason?: string } = {}): Promise<ApprovalRecord> {
return await this.decide(id, "approved", decision);
}
public async deny(id: string | undefined, decision: { reason?: string } = {}): Promise<ApprovalRecord> {
return await this.decide(id, "denied", decision);
}
public async consume(id: string, reference: ApprovalReference = {}): Promise<ApprovalRecord> {
validateId(id);
return await this.mutate(async (state) => {
const record = this.require(state, id);
if (record.status !== "approved") {
throw new AgenticError(
"TRANSACTION_STATE",
`Approval ${id} is ${record.status}; only approved records can be consumed.`,
);
}
const now = new Date().toISOString();
record.status = "consumed";
record.consumedAt = now;
record.updatedAt = now;
record.reference = { ...record.reference, ...reference };
await this.journal.record({
category: "approval",
action: "consumed",
summary: `${record.id} consumed: ${record.title}`,
details: { reference: record.reference },
});
return record;
});
}
public async proposePlan(plan: PlanDocument): Promise<ApprovalRecord> {
return await this.mutate(async (state) => {
const now = new Date().toISOString();
const created: ApprovalRecord = {
version: 1,
id: createId("plan"),
kind: "plan",
status: "pending",
operationHash: `plan\u0000${createId("hash")}`,
title: plan.title.slice(0, 300),
description: plan.objective.slice(0, 4000),
destructive: false,
reference: {},
plan,
createdAt: now,
updatedAt: now,
expiresAt: new Date(Date.parse(now) + EXPIRY_MS).toISOString(),
};
created.reference.planId = created.id;
// plan.md is written inside the lock, before the record enters state: a
// failed write leaves neither a new record nor a superseded predecessor.
await this.storage.writeText(this.planPath(created.id), renderPlanMarkdown(plan, created.id));
for (const existing of state.records) {
if (existing.kind === "plan" && LIVE_STATUSES.includes(existing.status)) {
existing.status = "superseded";
existing.updatedAt = now;
}
}
state.records.push(created);
await this.journal.record({
category: "approval",
action: "plan_proposed",
summary: `${created.id} proposed: ${created.title}`,
details: { steps: plan.steps.length },
});
return created;
});
}
public async activePlan(): Promise<ApprovalRecord | undefined> {
return (await this.snapshot()).activePlan;
}
public async pendingPlan(): Promise<ApprovalRecord | undefined> {
return (await this.snapshot()).pendingPlan;
}
public async completePlan(id: string, summary: string): Promise<ApprovalRecord> {
validateId(id);
return await this.mutate(async (state) => {
const record = this.require(state, id);
if (record.kind !== "plan" || record.status !== "approved") {
throw new AgenticError(
"TRANSACTION_STATE",
record.kind === "plan" && record.status === "pending"
? // Naming the wait, not just the state: a small model read the
// bare "is pending" as something it could retry, and looped.
`Plan ${id} is still awaiting the user's /accept; nothing runs until then. Stop and tell the user the plan id — calling complete or show again cannot change this.`
: `Plan ${id} is ${record.status}; only an approved plan can be completed.`,
);
}
const now = new Date().toISOString();
record.status = "completed";
record.completedAt = now;
record.updatedAt = now;
record.description = `${record.description}\n\nCompleted: ${summary.slice(0, 2000)}`;
await this.journal.record({
category: "approval",
action: "plan_completed",
summary: `${record.id} completed: ${summary.slice(0, 200)}`,
});
return record;
});
}
private async decide(
id: string | undefined,
status: "approved" | "denied",
decision: { reason?: string },
): Promise<ApprovalRecord> {
if (id !== undefined) validateId(id);
return await this.mutate(async (state) => {
const pending = state.records
.filter((record) => record.status === "pending")
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const record = id === undefined ? pending[0] : this.require(state, id);
if (!record) throw new AgenticError("NOT_FOUND", "No approval is pending.");
if (record.status !== "pending") {
throw new AgenticError(
"TRANSACTION_STATE",
`Approval ${record.id} is ${record.status}; only pending records can be decided.`,
);
}
const now = new Date().toISOString();
record.status = status;
record.decidedAt = now;
record.updatedAt = now;
record.decision = {
by: "chat_command",
at: now,
...(decision.reason?.trim() ? { reason: decision.reason.trim().slice(0, 1000) } : {}),
};
await this.journal.record({
category: "approval",
action: status,
summary: `${record.id} ${status}: ${record.title}`,
...(record.decision.reason ? { details: { reason: record.decision.reason } } : {}),
});
return record;
});
}
private require(state: ApprovalState, id: string): ApprovalRecord {
const record = state.records.find((candidate) => candidate.id === id);
if (!record) throw new AgenticError("NOT_FOUND", `Approval not found: ${id}`);
return record;
}
/**
* Loads state and applies expiry in memory. Nothing is written here: reads
* return the expired view directly, and writers persist it as part of their
* own mutation (expiry is idempotent, so the order does not matter).
*/
private async load(): Promise<ApprovalState> {
if (!(await this.storage.exists(this.stateRelative))) {
return { version: 1, records: [] };
}
const state = await this.storage.readJson<ApprovalState>(this.stateRelative);
const now = Date.now();
for (const record of state.records) {
if (!EXPIRABLE_STATUSES.includes(record.status)) continue;
// State written before plans carried an expiry must not become immortal,
// so a missing stamp is dated from `createdAt`, which every record has.
const expiresAt = record.expiresAt
? Date.parse(record.expiresAt)
: Date.parse(record.createdAt) + EXPIRY_MS;
if (Number.isFinite(expiresAt) && expiresAt < now) {
record.status = "expired";
record.updatedAt = new Date(now).toISOString();
}
}
return state;
}
private prune(state: ApprovalState): void {
if (state.records.length <= MAX_RECORDS) return;
const live = state.records.filter((record) => LIVE_STATUSES.includes(record.status));
const terminal = state.records
.filter((record) => !LIVE_STATUSES.includes(record.status))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, Math.max(0, MAX_RECORDS - live.length));
state.records = [...live, ...terminal].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
private async mutate<T>(action: (state: ApprovalState) => T | Promise<T>): Promise<T> {
const key = `${this.storage.boundary.realRoot}\0approvals`;
const previous = approvalLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
approvalLocks.set(key, queued);
await previous;
try {
const state = await this.load();
const result = await action(state);
this.prune(state);
await this.storage.writeJson(this.stateRelative, state);
return result;
} finally {
release();
if (approvalLocks.get(key) === queued) approvalLocks.delete(key);
}
}
}
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import type { MutationKind } from "./permissions";
import { renderPlanMarkdown, type PlanDocument } from "./planDocument";
export type ApprovalKind = MutationKind | "plan";
export type ApprovalStatus =
| "pending"
| "approved"
| "denied"
| "consumed"
| "completed"
| "superseded"
| "expired";
export interface ApprovalReference {
transactionId?: string;
planId?: string;
runId?: string;
jobId?: string;
}
export interface ApprovalResume {
tool: string;
arguments: Record<string, unknown>;
}
export interface ApprovalDecision {
by: "chat_command";
reason?: string;
at: string;
}
export interface ApprovalRecord {
version: 1;
id: string;
kind: ApprovalKind;
status: ApprovalStatus;
operationHash: string;
title: string;
description: string;
destructive: boolean;
reference: ApprovalReference;
resume?: ApprovalResume;
plan?: PlanDocument;
createdAt: string;
updatedAt: string;
decidedAt?: string;
consumedAt?: string;
completedAt?: string;
expiresAt?: string;
decision?: ApprovalDecision;
}
export interface StageInput {
kind: MutationKind;
operationHash: string;
title: string;
description: string;
destructive: boolean;
reference: ApprovalReference;
resume: ApprovalResume;
}
interface ApprovalState {
version: 1;
records: ApprovalRecord[];
}
export interface ApprovalSnapshot {
/** The most recent pending plan, if any. */
pendingPlan?: ApprovalRecord;
/** The most recent approved plan, if any. */
activePlan?: ApprovalRecord;
/** Pending non-plan records, newest first. */
pendingApprovals: ApprovalRecord[];
}
/**
* The one derivation of "what is the model waiting on", shared by the tools
* (`workspace_inspect` capabilities/overview) and the prompt preprocessor's
* mode hint. `records` must be newest-first, as `list()` returns them.
*/
export function summarizeApprovals(records: ApprovalRecord[]): ApprovalSnapshot {
const pendingPlan = records.find((record) => record.kind === "plan" && record.status === "pending");
const activePlan = records.find((record) => record.kind === "plan" && record.status === "approved");
return {
...(pendingPlan ? { pendingPlan } : {}),
...(activePlan ? { activePlan } : {}),
pendingApprovals: records.filter((record) => record.kind !== "plan" && record.status === "pending"),
};
}
const approvalLocks = new Map<string, Promise<void>>();
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const MAX_RECORDS = 300;
const LIVE_STATUSES: ApprovalStatus[] = ["pending", "approved"];
const MATCHABLE_STATUSES: ApprovalStatus[] = ["pending", "approved", "denied"];
/**
* Records in these states expire 24 h after they were created — a denial is
* sticky for the identical operation only until then, and an approved plan is
* an unlock only for as long as the conversation that earned it plausibly
* lasts. Approvals are workspace-scoped, not chat-scoped, and there is no chat
* id to scope them with; a plan approved on Monday and never completed used to
* make Plan mode behave as Auto in a new chat on Wednesday, because
* `activePlan()` simply returns the newest approved plan. A time bound is the
* closest thing to a session bound the store can express. `completed` and
* `superseded` are terminal and never expire.
*/
const EXPIRABLE_STATUSES: ApprovalStatus[] = ["pending", "approved", "denied"];
function validateId(id: string): void {
if (!/^(approval|plan)_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid approval id: ${id}`);
}
}
export class ApprovalStore {
private readonly approvalsRelative: string;
private readonly plansRelative: string;
private readonly stateRelative: string;
public constructor(
private readonly storage: InternalStorage,
private readonly journal: Journal,
) {
this.approvalsRelative = storage.relative("approvals");
this.plansRelative = storage.relative("plans");
this.stateRelative = storage.relative("approvals", "state.json");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.approvalsRelative);
}
public stateRelativePath(): string {
return this.stateRelative;
}
public planPath(id: string): string {
validateId(id);
return `${this.plansRelative}/${id}/plan.md`;
}
/**
* Reads are load-only: they take no lock and never write `state.json`.
* Expiry is applied in memory by `load()`, so a stale record reads as
* `expired` immediately; the next real mutation persists the same result.
* Plans expire on the same rule as everything else — see EXPIRABLE_STATUSES.
*/
public async list(filter: { status?: ApprovalStatus; kind?: ApprovalKind } = {}): Promise<ApprovalRecord[]> {
const state = await this.load();
return state.records
.filter((record) => (filter.status ? record.status === filter.status : true))
.filter((record) => (filter.kind ? record.kind === filter.kind : true))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
public async get(id: string): Promise<ApprovalRecord> {
validateId(id);
const state = await this.load();
const record = state.records.find((candidate) => candidate.id === id);
if (!record) throw new AgenticError("NOT_FOUND", `Approval not found: ${id}`);
return record;
}
/** One read of the store, summarised into the pending/approved plan and pending approvals. */
public async snapshot(): Promise<ApprovalSnapshot> {
return summarizeApprovals(await this.list());
}
public async find(operationHash: string): Promise<ApprovalRecord | undefined> {
const records = await this.list();
return records.find(
(record) =>
record.operationHash === operationHash && MATCHABLE_STATUSES.includes(record.status),
);
}
public async mostRecentPending(): Promise<ApprovalRecord | undefined> {
return (await this.list({ status: "pending" }))[0];
}
public async stage(input: StageInput): Promise<ApprovalRecord> {
return await this.mutate(async (state) => {
const existing = state.records.find(
(record) => record.operationHash === input.operationHash && record.status === "pending",
);
if (existing) return existing;
const now = new Date();
const record: ApprovalRecord = {
version: 1,
id: createId("approval"),
kind: input.kind,
status: "pending",
operationHash: input.operationHash,
title: input.title.slice(0, 300),
description: input.description.slice(0, 4000),
destructive: input.destructive,
reference: { ...input.reference },
resume: input.resume,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(),
};
state.records.push(record);
await this.journal.record({
category: "approval",
action: "staged",
summary: `${record.id} staged: ${record.title}`,
details: { kind: record.kind, operationHash: record.operationHash },
});
return record;
});
}
public async approve(id: string | undefined, decision: { reason?: string } = {}): Promise<ApprovalRecord> {
return await this.decide(id, "approved", decision);
}
public async deny(id: string | undefined, decision: { reason?: string } = {}): Promise<ApprovalRecord> {
return await this.decide(id, "denied", decision);
}
public async consume(id: string, reference: ApprovalReference = {}): Promise<ApprovalRecord> {
validateId(id);
return await this.mutate(async (state) => {
const record = this.require(state, id);
if (record.status !== "approved") {
throw new AgenticError(
"TRANSACTION_STATE",
`Approval ${id} is ${record.status}; only approved records can be consumed.`,
);
}
const now = new Date().toISOString();
record.status = "consumed";
record.consumedAt = now;
record.updatedAt = now;
record.reference = { ...record.reference, ...reference };
await this.journal.record({
category: "approval",
action: "consumed",
summary: `${record.id} consumed: ${record.title}`,
details: { reference: record.reference },
});
return record;
});
}
public async proposePlan(plan: PlanDocument): Promise<ApprovalRecord> {
return await this.mutate(async (state) => {
const now = new Date().toISOString();
const created: ApprovalRecord = {
version: 1,
id: createId("plan"),
kind: "plan",
status: "pending",
operationHash: `plan\u0000${createId("hash")}`,
title: plan.title.slice(0, 300),
description: plan.objective.slice(0, 4000),
destructive: false,
reference: {},
plan,
createdAt: now,
updatedAt: now,
expiresAt: new Date(Date.parse(now) + EXPIRY_MS).toISOString(),
};
created.reference.planId = created.id;
// plan.md is written inside the lock, before the record enters state: a
// failed write leaves neither a new record nor a superseded predecessor.
await this.storage.writeText(this.planPath(created.id), renderPlanMarkdown(plan, created.id));
for (const existing of state.records) {
if (existing.kind === "plan" && LIVE_STATUSES.includes(existing.status)) {
existing.status = "superseded";
existing.updatedAt = now;
}
}
state.records.push(created);
await this.journal.record({
category: "approval",
action: "plan_proposed",
summary: `${created.id} proposed: ${created.title}`,
details: { steps: plan.steps.length },
});
return created;
});
}
public async activePlan(): Promise<ApprovalRecord | undefined> {
return (await this.snapshot()).activePlan;
}
public async pendingPlan(): Promise<ApprovalRecord | undefined> {
return (await this.snapshot()).pendingPlan;
}
public async completePlan(id: string, summary: string): Promise<ApprovalRecord> {
validateId(id);
return await this.mutate(async (state) => {
const record = this.require(state, id);
if (record.kind !== "plan" || record.status !== "approved") {
throw new AgenticError(
"TRANSACTION_STATE",
record.kind === "plan" && record.status === "pending"
? // Naming the wait, not just the state: a small model read the
// bare "is pending" as something it could retry, and looped.
`Plan ${id} is still awaiting the user's /accept; nothing runs until then. Stop and tell the user the plan id — calling complete or show again cannot change this.`
: `Plan ${id} is ${record.status}; only an approved plan can be completed.`,
);
}
const now = new Date().toISOString();
record.status = "completed";
record.completedAt = now;
record.updatedAt = now;
record.description = `${record.description}\n\nCompleted: ${summary.slice(0, 2000)}`;
await this.journal.record({
category: "approval",
action: "plan_completed",
summary: `${record.id} completed: ${summary.slice(0, 200)}`,
});
return record;
});
}
private async decide(
id: string | undefined,
status: "approved" | "denied",
decision: { reason?: string },
): Promise<ApprovalRecord> {
if (id !== undefined) validateId(id);
return await this.mutate(async (state) => {
const pending = state.records
.filter((record) => record.status === "pending")
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const record = id === undefined ? pending[0] : this.require(state, id);
if (!record) throw new AgenticError("NOT_FOUND", "No approval is pending.");
if (record.status !== "pending") {
throw new AgenticError(
"TRANSACTION_STATE",
`Approval ${record.id} is ${record.status}; only pending records can be decided.`,
);
}
const now = new Date().toISOString();
record.status = status;
record.decidedAt = now;
record.updatedAt = now;
record.decision = {
by: "chat_command",
at: now,
...(decision.reason?.trim() ? { reason: decision.reason.trim().slice(0, 1000) } : {}),
};
await this.journal.record({
category: "approval",
action: status,
summary: `${record.id} ${status}: ${record.title}`,
...(record.decision.reason ? { details: { reason: record.decision.reason } } : {}),
});
return record;
});
}
private require(state: ApprovalState, id: string): ApprovalRecord {
const record = state.records.find((candidate) => candidate.id === id);
if (!record) throw new AgenticError("NOT_FOUND", `Approval not found: ${id}`);
return record;
}
/**
* Loads state and applies expiry in memory. Nothing is written here: reads
* return the expired view directly, and writers persist it as part of their
* own mutation (expiry is idempotent, so the order does not matter).
*/
private async load(): Promise<ApprovalState> {
if (!(await this.storage.exists(this.stateRelative))) {
return { version: 1, records: [] };
}
const state = await this.storage.readJson<ApprovalState>(this.stateRelative);
const now = Date.now();
for (const record of state.records) {
if (!EXPIRABLE_STATUSES.includes(record.status)) continue;
// State written before plans carried an expiry must not become immortal,
// so a missing stamp is dated from `createdAt`, which every record has.
const expiresAt = record.expiresAt
? Date.parse(record.expiresAt)
: Date.parse(record.createdAt) + EXPIRY_MS;
if (Number.isFinite(expiresAt) && expiresAt < now) {
record.status = "expired";
record.updatedAt = new Date(now).toISOString();
}
}
return state;
}
private prune(state: ApprovalState): void {
if (state.records.length <= MAX_RECORDS) return;
const live = state.records.filter((record) => LIVE_STATUSES.includes(record.status));
const terminal = state.records
.filter((record) => !LIVE_STATUSES.includes(record.status))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, Math.max(0, MAX_RECORDS - live.length));
state.records = [...live, ...terminal].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
private async mutate<T>(action: (state: ApprovalState) => T | Promise<T>): Promise<T> {
const key = `${this.storage.boundary.realRoot}\0approvals`;
const previous = approvalLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
approvalLocks.set(key, queued);
await previous;
try {
const state = await this.load();
const result = await action(state);
this.prune(state);
await this.storage.writeJson(this.stateRelative, state);
return result;
} finally {
release();
if (approvalLocks.get(key) === queued) approvalLocks.delete(key);
}
}
}