// ---------------------------------------------------------------------------// PendingApprovalStore — durable "this call is waiting on a human" records.//// A paused execution normally lives as a suspended fiber inside one engine// instance. That works whenever the pause and the resume are served by the same// engine (local and desktop, which share one boot-built executor, and any// same-instance resume elsewhere). It does NOT work on a host whose HTTP API// builds a fresh executor per request: the fiber is discarded with the request// that created it, so the resume arrives at an engine that has never heard of// the execution. That is the whole of the artifact-approval bug — the failure is// immediate, not a lease expiring.//// An artifact action is reconstructible where general code is not. The shell's// grammar is exactly ONE tool call (`parseToolCallCode`), and the approval gate// runs strictly BEFORE the tool is invoked, so a paused artifact action holds no// partial work — only a resolved call that has not happened yet. Recording that// call durably and running it when the approval arrives is therefore equivalent// to resuming the fiber, without requiring the fiber to still exist.//// Records live in the existing owner-scoped `blob` table (no new table, no// migration). They are single-use: consumed on resume, so one approval authorizes// exactly one invocation and a replayed resume cannot re-run the call.// ---------------------------------------------------------------------------import { Effect, Option, Schema } from "effect";import type { BlobStore } from "./blob";import type { StorageError } from "./fuma-runtime";/** How long a recorded approval stays valid. Human-scale: someone reading an * argument preview and deciding takes minutes, not seconds. Past this the * record is treated as absent and the shell asks the user to trigger the * action again, which is safe because nothing has run yet. */export const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;/** * The resolved call an approval authorizes. * * `code` is the SERVER-BUILT emission (`resolveArtifactAction` rewrote the * artifact's role into a real connection address) — never the string the iframe * sent. Re-running it after approval therefore runs exactly what the pause * described, and an iframe cannot widen its own grant between pause and resume. */export const PendingApproval = Schema.Struct({ executionId: Schema.String, /** The artifact the call came from, for the ownership re-check on resume. */ artifactId: Schema.String, /** The resolved, server-built tool-call code. */ code: Schema.String, /** The tool address the human was shown, so the record can be audited and so * a resume cannot be pointed at a different call than the one approved. */ address: Schema.String, /** Epoch ms after which this record is ignored. */ expiresAt: Schema.Number,});export type PendingApproval = typeof PendingApproval.Type;const encodePendingApproval = Schema.encodeUnknownSync(Schema.fromJsonString(PendingApproval));const decodePendingApproval = Schema.decodeUnknownOption(Schema.fromJsonString(PendingApproval));/** * Durable store for pending approvals, scoped to one owner partition. * * `get` is a strict read: an expired or unparseable record reads as absent, and * either way the entry is not returned. Callers treat absent as "the approval * window closed — ask the user to trigger the action again". */export interface PendingApprovalStore { readonly put: (approval: PendingApproval) => Effect.Effect<void, StorageError>; /** Read and DELETE in one step — an approval authorizes exactly one run. */ readonly consume: (executionId: string) => Effect.Effect<PendingApproval | null, StorageError>; /** Drop a record without running it (decline/cancel). */ readonly discard: (executionId: string) => Effect.Effect<void, StorageError>;}/** * Bind a `BlobStore` to one owner partition as a pending-approval store. * * The namespace is the owner partition plus a fixed suffix, matching how plugin * blobs namespace themselves — so a record is only ever visible to the scope * that wrote it, and the ownership check on resume is the same read that fetches * the record rather than a separate test that could drift from it. */export const makePendingApprovalStore = ( blobs: BlobStore, partition: string, now: () => number = Date.now,): PendingApprovalStore => { const namespace = `${partition}/@pending-approval`; return { put: (approval) => blobs.put(namespace, approval.executionId, encodePendingApproval(approval)), consume: (executionId) => Effect.gen(function* () { const raw = yield* blobs.get(namespace, executionId); if (raw === null) return null; // Consume before validating: a record we are about to reject is a record // nobody should be able to retry against. yield* blobs.delete(namespace, executionId); const decoded = decodePendingApproval(raw); if (Option.isNone(decoded)) return null; const approval = decoded.value; if (approval.expiresAt <= now()) return null; return approval; }), discard: (executionId) => blobs.delete(namespace, executionId), };};
// ---------------------------------------------------------------------------
// PendingApprovalStore — durable "this call is waiting on a human" records.
//
// A paused execution normally lives as a suspended fiber inside one engine
// instance. That works whenever the pause and the resume are served by the same
// engine (local and desktop, which share one boot-built executor, and any
// same-instance resume elsewhere). It does NOT work on a host whose HTTP API
// builds a fresh executor per request: the fiber is discarded with the request
// that created it, so the resume arrives at an engine that has never heard of
// the execution. That is the whole of the artifact-approval bug — the failure is
// immediate, not a lease expiring.
//
// An artifact action is reconstructible where general code is not. The shell's
// grammar is exactly ONE tool call (`parseToolCallCode`), and the approval gate
// runs strictly BEFORE the tool is invoked, so a paused artifact action holds no
// partial work — only a resolved call that has not happened yet. Recording that
// call durably and running it when the approval arrives is therefore equivalent
// to resuming the fiber, without requiring the fiber to still exist.
//
// Records live in the existing owner-scoped `blob` table (no new table, no
// migration). They are single-use: consumed on resume, so one approval authorizes
// exactly one invocation and a replayed resume cannot re-run the call.
// ---------------------------------------------------------------------------
import { Effect, Option, Schema } from "effect";
import type { BlobStore } from "./blob";
import type { StorageError } from "./fuma-runtime";
/** How long a recorded approval stays valid. Human-scale: someone reading an
* argument preview and deciding takes minutes, not seconds. Past this the
* record is treated as absent and the shell asks the user to trigger the
* action again, which is safe because nothing has run yet. */
export const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
/**
* The resolved call an approval authorizes.
*
* `code` is the SERVER-BUILT emission (`resolveArtifactAction` rewrote the
* artifact's role into a real connection address) — never the string the iframe
* sent. Re-running it after approval therefore runs exactly what the pause
* described, and an iframe cannot widen its own grant between pause and resume.
*/
export const PendingApproval = Schema.Struct({
executionId: Schema.String,
/** The artifact the call came from, for the ownership re-check on resume. */
artifactId: Schema.String,
/** The resolved, server-built tool-call code. */
code: Schema.String,
/** The tool address the human was shown, so the record can be audited and so
* a resume cannot be pointed at a different call than the one approved. */
address: Schema.String,
/** Epoch ms after which this record is ignored. */
expiresAt: Schema.Number,
});
export type PendingApproval = typeof PendingApproval.Type;
const encodePendingApproval = Schema.encodeUnknownSync(Schema.fromJsonString(PendingApproval));
const decodePendingApproval = Schema.decodeUnknownOption(Schema.fromJsonString(PendingApproval));
/**
* Durable store for pending approvals, scoped to one owner partition.
*
* `get` is a strict read: an expired or unparseable record reads as absent, and
* either way the entry is not returned. Callers treat absent as "the approval
* window closed — ask the user to trigger the action again".
*/
export interface PendingApprovalStore {
readonly put: (approval: PendingApproval) => Effect.Effect<void, StorageError>;
/** Read and DELETE in one step — an approval authorizes exactly one run. */
readonly consume: (executionId: string) => Effect.Effect<PendingApproval | null, StorageError>;
/** Drop a record without running it (decline/cancel). */
readonly discard: (executionId: string) => Effect.Effect<void, StorageError>;
}
/**
* Bind a `BlobStore` to one owner partition as a pending-approval store.
*
* The namespace is the owner partition plus a fixed suffix, matching how plugin
* blobs namespace themselves — so a record is only ever visible to the scope
* that wrote it, and the ownership check on resume is the same read that fetches
* the record rather than a separate test that could drift from it.
*/
export const makePendingApprovalStore = (
blobs: BlobStore,
partition: string,
now: () => number = Date.now,
): PendingApprovalStore => {
const namespace = `${partition}/@pending-approval`;
return {
put: (approval) => blobs.put(namespace, approval.executionId, encodePendingApproval(approval)),
consume: (executionId) =>
Effect.gen(function* () {
const raw = yield* blobs.get(namespace, executionId);
if (raw === null) return null;
// Consume before validating: a record we are about to reject is a record
// nobody should be able to retry against.
yield* blobs.delete(namespace, executionId);
const decoded = decodePendingApproval(raw);
if (Option.isNone(decoded)) return null;
const approval = decoded.value;
if (approval.expiresAt <= now()) return null;
return approval;
}),
discard: (executionId) => blobs.delete(namespace, executionId),
};
};