diff --git a/apps/demo/public/summon.css b/apps/demo/public/summon.css index 997d5bb..eb6ffbb 100644 --- a/apps/demo/public/summon.css +++ b/apps/demo/public/summon.css @@ -289,6 +289,84 @@ nav.summon-nav a.summon-brand:hover { .log .op-meta { color: var(--text-muted); } .log .raw { color: var(--color-gray-400); } +.approval-stack { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 80; + display: grid; + gap: 10px; + width: min(360px, calc(100vw - 32px)); +} + +.approval-card { + display: grid; + gap: 8px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--background-default); + box-shadow: var(--shadow-elevated); + padding: 14px; +} + +.approval-card span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.approval-card strong { + color: var(--text-default); + font-size: 15px; + line-height: 1.25; +} + +.approval-card p { + margin: 0; + color: var(--text-alt); + font-size: 12px; +} + +.approval-card pre { + max-height: 120px; + margin: 0; + overflow: auto; + border: 1px solid var(--border-default); + border-radius: 6px; + background: var(--background-alt); + color: var(--text-alt); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.45; + padding: 8px; + white-space: pre-wrap; +} + +.approval-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.approval-actions button { + min-width: 76px; + height: 32px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--background-default); + color: var(--text-default); + cursor: pointer; + font-weight: 700; +} + +.approval-actions .approval-approve { + background: var(--background-inverse); + color: var(--text-inverse); +} + .summary { padding: 12px 18px; border-top: 1px solid var(--border-default); diff --git a/apps/demo/src/capabilities.ts b/apps/demo/src/capabilities.ts index d903359..86d6f52 100644 --- a/apps/demo/src/capabilities.ts +++ b/apps/demo/src/capabilities.ts @@ -11,6 +11,8 @@ import { defineDataResource, defineWorkerAction, defineWorkerResource, + type ApprovalDecision, + type ApprovalRequest, type CapabilityDefinition, type CapabilityRegistry, } from '@anarchitecture/summon'; @@ -55,6 +57,12 @@ const summonArgsSchema = z.object({ type SearchResult = z.infer; type SummonArgs = z.infer; +type PublishArgs = z.infer; + +interface PublishSummaryPlan { + title: string; + channel: string; +} export interface DemoModelSelectionPayload { modelProvider?: string | null; @@ -87,6 +95,13 @@ export interface DemoHandlerOptions { * streaming machinery needed to spawn sibling sandboxes. */ onSummon?: IntentHandler; + /** + * Optional because batch/demo surfaces may run without a visible host + * approval panel. Browser hosts should render their own approve/deny UI here. + */ + onApprovalRequest?: ( + request: ApprovalRequest, + ) => Promise | ApprovalDecision; } export function createDemoCapabilityRegistry( @@ -467,16 +482,31 @@ export function createDemoCapabilityRegistry( 'Ask the host for approval, then publish a titled summary only if approved. Use when the user explicitly asks to approve, confirm, publish, commit, send, update, or operate.', argsSchema: publishArgsSchema, stateShape: - '{published: boolean, publishedTitle: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}', + '{published: boolean, publishedTitle: string | null, publishApprovalRequestId: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}', approval: { - request: ({ title }) => { - const approved = window.confirm(`Approve publishing "${title}"?`); - return approved ? 'approved' : { status: 'denied', reason: 'Demo approval denied' }; + stateKeys: { + requestId: 'publishApprovalRequestId', + pending: 'publishApprovalPending', + approved: 'publishApprovalApproved', + denied: 'publishApprovalDenied', + error: 'publishApprovalError', + }, + prepare: ({ title }) => ({ + summary: `Publish "${title}"`, + details: { title, channel: 'demo-updates' }, + plan: { title, channel: 'demo-updates' }, + }), + request: ({ title }, request) => { + log(`-> approval requested "${title}"`); + if (request && opts.onApprovalRequest) return opts.onApprovalRequest(request); + return 'approved'; }, }, - handler: ({ args, push }) => { - log(`-> publish_summary "${args.title}"`); - push({ published: true, publishedTitle: args.title }); + handler: ({ args, approval, push }) => { + const plan = approval?.plan as PublishSummaryPlan | undefined; + const title = plan?.title ?? args.title; + log(`-> publish_summary "${title}"`); + push({ published: true, publishedTitle: title }); }, }), ]; diff --git a/apps/demo/src/generate-main.ts b/apps/demo/src/generate-main.ts index 7a3647f..79e0af6 100644 --- a/apps/demo/src/generate-main.ts +++ b/apps/demo/src/generate-main.ts @@ -34,6 +34,8 @@ import { } from '@anarchitecture/summon/engine'; import { PolicyEngine, + type ApprovalDecision, + type ApprovalRequest, type SurfacePolicy, } from '@anarchitecture/summon'; import { createEventStore, type DevtoolsEvent } from '@anarchitecture/summon/devtools'; @@ -272,10 +274,105 @@ function logLine(cls: string, text: string) { log.scrollTop = log.scrollHeight; } +function requestHostApproval(request: ApprovalRequest): Promise { + logLine('op-meta', `approval pending: ${request.summary}`); + return new Promise((resolve) => { + const card = document.createElement('section'); + card.className = 'approval-card'; + card.dataset.approvalId = request.id; + + const eyebrow = document.createElement('span'); + eyebrow.textContent = request.capability; + + const title = document.createElement('strong'); + title.textContent = request.summary; + + const meta = document.createElement('p'); + meta.textContent = `Request ${request.id}`; + + card.append(eyebrow, title, meta); + + const details = formatApprovalDetails(request.details); + if (details) { + const detailsEl = document.createElement('pre'); + detailsEl.textContent = details; + card.appendChild(detailsEl); + } + + const actions = document.createElement('div'); + actions.className = 'approval-actions'; + const deny = document.createElement('button'); + deny.type = 'button'; + deny.textContent = 'Deny'; + const approve = document.createElement('button'); + approve.type = 'button'; + approve.className = 'approval-approve'; + approve.textContent = 'Approve'; + actions.append(deny, approve); + card.appendChild(actions); + + let settled = false; + const finish = (decision: ApprovalDecision) => { + if (settled) return; + settled = true; + pendingApprovalCards.delete(request.id); + card.remove(); + if (approvalStack && approvalStack.childElementCount === 0) { + approvalStack.remove(); + approvalStack = null; + } + resolve(decision); + }; + + approve.addEventListener('click', () => { + logLine('op-add', `approval approved: ${request.id}`); + finish('approved'); + }); + deny.addEventListener('click', () => { + logLine('op-error', `approval denied: ${request.id}`); + finish({ status: 'denied', reason: 'Demo approval denied' }); + }); + + pendingApprovalCards.set(request.id, () => finish({ status: 'denied', reason: 'Approval request was replaced' })); + ensureApprovalStack().prepend(card); + }); +} + +function ensureApprovalStack(): HTMLElement { + if (approvalStack) return approvalStack; + approvalStack = document.createElement('div'); + approvalStack.className = 'approval-stack'; + document.body.appendChild(approvalStack); + return approvalStack; +} + +function clearApprovalCards(reason: string): void { + const settleCards = [...pendingApprovalCards.values()]; + pendingApprovalCards.clear(); + for (const settle of settleCards) settle(); + if (approvalStack) { + approvalStack.remove(); + approvalStack = null; + } + if (settleCards.length > 0) logLine('op-error', reason); +} + +function formatApprovalDetails(details: unknown): string { + if (details === undefined || details === null) return ''; + if (typeof details === 'string') return details; + try { + return JSON.stringify(details, null, 2); + } catch { + return String(details); + } +} + let directions: DirectionInfo[] = []; let ghostRoots: GhostRootInfo[] = []; let modelProviders: ModelProviderInfo[] = []; let defaultModelProviderId: string | null = null; +let approvalStack: HTMLElement | null = null; +const pendingApprovalCards = new Map void>(); let showcaseScenarios: ShowcaseScenario[] = [...SHOWCASE_SCENARIOS]; let currentEffectiveSurfacePlan: SurfacePlan | null = null; let currentShape: string | null = null; @@ -1248,6 +1345,7 @@ function respawn( active: ActiveContract = readActiveContract(), initialHtml = '', ): SandboxHandle { + clearApprovalCards('Approval request was replaced'); if (componentIslands) { componentIslands.destroy(); componentIslands = null; @@ -1281,6 +1379,7 @@ function respawn( modelSelection: readModelSelection, onLog: (m) => logLine('op-add', m), onError: (m) => logLine('op-error', m), + onApprovalRequest: requestHostApproval, // summon needs DOM access (spawns a sibling iframe) and the streaming // pipeline, so this page supplies the handler while the registry owns // its prompt contract and schema validation. diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md index d0746bf..e1e110f 100644 --- a/docs/adoption/integration.md +++ b/docs/adoption/integration.md @@ -24,6 +24,7 @@ import { z } from 'zod'; import { createCapabilityRegistry, defineAction, + defineApprovalAction, defineDataResource, } from '@anarchitecture/summon'; @@ -69,6 +70,45 @@ const capabilityContract = registry.toContract(); `capabilityContract.pack` is model-facing. `capabilityContract.validationCapabilities` and `capabilityContract.initialState` are runtime-facing. +Approval actions are still host tools. The difference is that the host can +prepare the exact operation before asking for a decision. The generated surface +gets only small status state such as pending, approved, denied, failed, and a +request id; approve and deny controls stay in trusted host UI. + +```ts +defineApprovalAction({ + name: 'publish_summary', + description: 'Publish a prepared summary only after host approval.', + argsSchema: z.object({ draftId: z.string(), title: z.string() }), + stateShape: { + published: 'boolean', + publishedDraftId: 'string | null', + publishApprovalRequestId: 'string | null', + publishApprovalPending: 'boolean', + publishApprovalApproved: 'boolean', + publishApprovalDenied: 'boolean', + publishApprovalError: 'string | null', + }, + approval: { + prepare: ({ draftId, title }) => ({ + summary: `Publish "${title}"`, + details: { draftId }, + plan: { draftId, endpoint: `/api/drafts/${draftId}/publish` }, + }), + request: (_args, request) => approvalPanel.open(request), + }, + handler: async ({ approval, push }) => { + const plan = approval!.plan as { draftId: string; endpoint: string }; + await fetch(plan.endpoint, { method: 'POST' }); + push({ published: true, publishedDraftId: plan.draftId }); + }, +}); +``` + +Existing `approval.request(args)` callbacks remain valid. Hosts that need +durable approvals should persist the `ApprovalRequest` they receive in +`request`; Summon core intentionally does not add a workflow store. + ## 2. Register Trusted Host Components Trusted host components let the generated UI place a host-rendered component diff --git a/docs/adoption/security.md b/docs/adoption/security.md index 4c41803..6efdc19 100644 --- a/docs/adoption/security.md +++ b/docs/adoption/security.md @@ -90,6 +90,12 @@ requires a compatible host registry for the same reason. - Use `defineWorkerAction` / `defineWorkerResource` for host-owned background work and `defineApprovalAction` for operations that require a host approval adapter before the handler runs. +- Treat approval as a workflow owned by the host, not a generated modal. For + approval actions, the host may `prepare` the exact operation into an + `ApprovalRequest`; the user approves or denies that request in host UI; the + approved handler executes from `ctx.approval.plan`. Summon core does not + persist approval requests, and generated surfaces should render only waiting, + approved, denied, or failed state. - Proxy external data and assets through host handlers. The sandbox should see validated state and data URLs, not credentials or network endpoints. - Treat component definitions as trusted host code. Register only components diff --git a/examples/surface-gallery/src/capabilities.ts b/examples/surface-gallery/src/capabilities.ts index f917d8b..61e59d0 100644 --- a/examples/surface-gallery/src/capabilities.ts +++ b/examples/surface-gallery/src/capabilities.ts @@ -5,6 +5,8 @@ import { defineDataResource, defineWorkerAction, defineWorkerResource, + type ApprovalDecision, + type ApprovalRequest, type CapabilityDefinition, type CapabilityRegistry, } from '@anarchitecture/summon'; @@ -14,6 +16,9 @@ export interface GalleryCapabilityOptions { onLog?: (message: string) => void; onStatePreview?: (state: Record) => void; modelSelection?: () => object; + onApprovalRequest?: ( + request: ApprovalRequest, + ) => Promise | ApprovalDecision; } const chooseArgsSchema = z.object({ option: z.string().trim().min(1) }); @@ -35,6 +40,12 @@ const analysisResultSchema = z.object({ }); type SearchResult = z.infer; +type PublishArgs = z.infer; + +interface PublishSummaryPlan { + title: string; + channel: string; +} export function createGalleryCapabilityRegistry( capabilityNames?: readonly string[], @@ -135,18 +146,31 @@ function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): Capabilit 'Request host approval, then publish a titled summary only if the host approves. Use for publish, send, update, commit, or operate flows.', argsSchema: publishArgsSchema, stateShape: - '{published: boolean, publishedTitle: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}', + '{published: boolean, publishedTitle: string | null, publishApprovalRequestId: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}', approval: { - request: ({ title }) => { + stateKeys: { + requestId: 'publishApprovalRequestId', + pending: 'publishApprovalPending', + approved: 'publishApprovalApproved', + denied: 'publishApprovalDenied', + error: 'publishApprovalError', + }, + prepare: ({ title }) => ({ + summary: `Publish "${title}"`, + details: { title, channel: 'gallery-updates' }, + plan: { title, channel: 'gallery-updates' }, + }), + request: ({ title }, request) => { log(`approval requested: ${title}`); - return window.confirm(`Approve publishing "${title}"?`) - ? 'approved' - : { status: 'denied', reason: 'Host denied approval' }; + if (request && opts.onApprovalRequest) return opts.onApprovalRequest(request); + return 'approved'; }, }, - handler: ({ args, push }) => { - log(`published: ${args.title}`); - push({ published: true, publishedTitle: args.title }); + handler: ({ args, approval, push }) => { + const plan = approval?.plan as PublishSummaryPlan | undefined; + const title = plan?.title ?? args.title; + log(`published: ${title}`); + push({ published: true, publishedTitle: title }); }, }), defineWorkerResource({ diff --git a/examples/surface-gallery/src/main.ts b/examples/surface-gallery/src/main.ts index b9441f6..4fd8852 100644 --- a/examples/surface-gallery/src/main.ts +++ b/examples/surface-gallery/src/main.ts @@ -1,6 +1,8 @@ import { compileSurfacePolicy, PolicyEngine, + type ApprovalDecision, + type ApprovalRequest, type ComponentPack, type CompiledSurfacePolicy, } from '@anarchitecture/summon'; @@ -129,6 +131,8 @@ let surfaceRenderedDuringRun = false; let acceptedStructuralLines = 0; let skippedLines = 0; let blockedLines = 0; +let approvalStack: HTMLElement | null = null; +const pendingApprovalCards = new Map void>(); events.subscribe(renderEvents); @@ -198,6 +202,7 @@ function selectPreset(id: string): void { } function respawnSandbox(initialHtml = ''): void { + clearApprovalCards('Approval request was replaced'); islands?.destroy(); islands = null; handle?.dispose(); @@ -208,6 +213,7 @@ function respawnSandbox(initialHtml = ''): void { const capabilityRegistry = createGalleryCapabilityRegistry(compiledPolicy.policy.grants, { onLog: pushHostMessage, modelSelection: readModelSelection, + onApprovalRequest: requestHostApproval, }); const capabilityContract = capabilityRegistry.toContract(); const componentRegistry = compiledPolicy.policy.components.length @@ -737,6 +743,99 @@ function pushHostMessage(message: string, opts: { attention?: boolean } = {}): v renderEvents(); } +function requestHostApproval(request: ApprovalRequest): Promise { + pushHostMessage(`approval pending: ${request.summary}`, { attention: true }); + return new Promise((resolve) => { + const card = document.createElement('section'); + card.className = 'approval-card'; + card.dataset.approvalId = request.id; + + const eyebrow = document.createElement('span'); + eyebrow.textContent = request.capability; + + const title = document.createElement('strong'); + title.textContent = request.summary; + + const meta = document.createElement('p'); + meta.textContent = `Request ${request.id}`; + + card.append(eyebrow, title, meta); + + const details = formatApprovalDetails(request.details); + if (details) { + const detailsEl = document.createElement('pre'); + detailsEl.textContent = details; + card.appendChild(detailsEl); + } + + const actions = document.createElement('div'); + actions.className = 'approval-actions'; + const approve = document.createElement('button'); + approve.type = 'button'; + approve.className = 'approval-approve'; + approve.textContent = 'Approve'; + const deny = document.createElement('button'); + deny.type = 'button'; + deny.textContent = 'Deny'; + actions.append(deny, approve); + card.appendChild(actions); + + let settled = false; + const finish = (decision: ApprovalDecision) => { + if (settled) return; + settled = true; + pendingApprovalCards.delete(request.id); + card.remove(); + if (approvalStack && approvalStack.childElementCount === 0) { + approvalStack.remove(); + approvalStack = null; + } + resolve(decision); + }; + + approve.addEventListener('click', () => { + pushHostMessage(`approval approved: ${request.id}`); + finish('approved'); + }); + deny.addEventListener('click', () => { + pushHostMessage(`approval denied: ${request.id}`, { attention: true }); + finish({ status: 'denied', reason: 'Host denied approval' }); + }); + + pendingApprovalCards.set(request.id, () => finish({ status: 'denied', reason: 'Approval request was replaced' })); + ensureApprovalStack().prepend(card); + }); +} + +function ensureApprovalStack(): HTMLElement { + if (approvalStack) return approvalStack; + approvalStack = document.createElement('div'); + approvalStack.className = 'approval-stack'; + document.body.appendChild(approvalStack); + return approvalStack; +} + +function clearApprovalCards(reason: string): void { + const settleCards = [...pendingApprovalCards.values()]; + pendingApprovalCards.clear(); + for (const settle of settleCards) settle(); + if (approvalStack) { + approvalStack.remove(); + approvalStack = null; + } + if (settleCards.length > 0) pushHostMessage(reason, { attention: true }); +} + +function formatApprovalDetails(details: unknown): string { + if (details === undefined || details === null) return ''; + if (typeof details === 'string') return details; + try { + return JSON.stringify(details, null, 2); + } catch { + return String(details); + } +} + function resetCounters(): void { acceptedStructuralLines = 0; skippedLines = 0; diff --git a/examples/surface-gallery/src/styles.css b/examples/surface-gallery/src/styles.css index 3a3c5a2..2f489bd 100644 --- a/examples/surface-gallery/src/styles.css +++ b/examples/surface-gallery/src/styles.css @@ -818,6 +818,85 @@ button { font-size: 12px; } +.approval-stack { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 90; + display: grid; + gap: 10px; + width: min(360px, calc(100vw - 32px)); +} + +.approval-card { + display: grid; + gap: 8px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + padding: 14px; +} + +.approval-card span { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.approval-card strong { + color: var(--ink); + font-size: 15px; + line-height: 1.25; +} + +.approval-card p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.approval-card pre { + max-height: 120px; + margin: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-quiet); + color: var(--ink-soft); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.45; + padding: 8px; + white-space: pre-wrap; +} + +.approval-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.approval-actions button { + min-width: 76px; + height: 32px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: var(--panel); + color: var(--ink); + cursor: pointer; + font-weight: 800; +} + +.approval-actions .approval-approve { + border-color: var(--green); + background: var(--green); + color: #ffffff; +} + @media (max-width: 1119px) { body { min-width: 0; diff --git a/examples/surface-gallery/tests/gallery-smoke.spec.ts b/examples/surface-gallery/tests/gallery-smoke.spec.ts index f1b80d7..153cfec 100644 --- a/examples/surface-gallery/tests/gallery-smoke.spec.ts +++ b/examples/surface-gallery/tests/gallery-smoke.spec.ts @@ -252,6 +252,106 @@ test('mocked generation renders and generated host tool requests update host sta await expect(page.locator('#event-log')).toContainText('host tool choose'); }); +test('approval publish uses host-owned approval card for approve and deny decisions', async ({ page }) => { + let captured: any = null; + await page.route('**/api/model-providers', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(modelProviderPayload()), + }); + }); + await page.route('**/api/ghost-roots', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + await page.route('**/api/generate', async (route) => { + captured = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'text/plain', + body: streamBody([ + { op: 'meta', path: '/surface-policy', value: captured.surfacePolicy }, + { + op: 'meta', + path: '/surface-plan', + value: { + purpose: 'operate', + runtime: 'declarative', + data: 'embedded', + authority: 'approval-gated', + persistence: 'ephemeral', + }, + }, + { op: 'set', path: '/screen', value: { sections: ['main'] } }, + { + op: 'add', + path: '/section/main', + html: ` +
+

Publish review

+ +

Waiting for host approval

+

Approved

+

Denied

+

+

Published

+
`, + }, + { + op: 'meta', + path: '/stream-graph-summary', + value: { + health: { + complete: true, + missingDeclared: [], + blockedCount: 0, + skippedCount: 0, + repairedCount: 0, + }, + sections: [], + }, + }, + ]), + }); + }); + + await page.goto('/'); + await page.locator('[data-preset-id="approval-publish"]').click(); + await page.locator('#run').click(); + await expect(page.locator('#status')).toContainText('done'); + + expect(captured.surfacePolicy).toEqual({ + tier: 'approval', + purpose: 'operate', + grants: ['publish_summary'], + }); + + const frame = page.frameLocator('#sandbox'); + await frame.getByRole('button', { name: 'Publish' }).click(); + await expect(page.locator('.approval-card')).toContainText('Publish "Approval smoke"'); + await expect(page.locator('.approval-card')).toContainText('gallery-updates'); + await expect(frame.getByTestId('waiting')).toBeVisible(); + await expect(page.locator('#state-preview')).toContainText('publish_summary'); + + await page.locator('.approval-card').getByRole('button', { name: 'Approve' }).click(); + await expect(page.locator('.approval-card')).toHaveCount(0); + await expect(frame.getByTestId('approved')).toBeVisible(); + await expect(frame.getByTestId('published')).toContainText('Approval smoke'); + + await page.locator('#run').click(); + await expect(page.locator('#status')).toContainText('done'); + await frame.getByRole('button', { name: 'Publish' }).click(); + await expect(page.locator('.approval-card')).toContainText('Publish "Approval smoke"'); + await page.locator('.approval-card').getByRole('button', { name: 'Deny' }).click(); + await expect(page.locator('.approval-card')).toHaveCount(0); + await expect(frame.getByTestId('denied')).toBeVisible(); + await expect(frame.getByTestId('published')).toBeHidden(); +}); + test('component island preset renders host overlays and reports invalid props', async ({ page }) => { let invalid = false; const requests: any[] = []; diff --git a/packages/host/src/capability-registry.ts b/packages/host/src/capability-registry.ts index c5eb5f3..d3099c7 100644 --- a/packages/host/src/capability-registry.ts +++ b/packages/host/src/capability-registry.ts @@ -71,16 +71,39 @@ export type ApprovalDecision = reason?: string; }; +export interface ApprovalPrepared { + summary: string; + details?: unknown; + plan: Plan; + expiresAt?: string; +} + +export interface ApprovalRequest { + id: string; + capability: string; + args: TArgs; + summary: string; + details?: unknown; + plan: Plan; + status: 'pending'; + expiresAt?: string; +} + export interface ApprovalStateKeys { + requestId: string; pending: string; approved: string; denied: string; error: string; } -export interface ApprovalActionDefinition extends ActionDefinition { +export interface ApprovalActionDefinition extends ActionDefinition { approval: { - request: (args: T) => Promise | ApprovalDecision; + prepare?: (args: T) => Promise> | ApprovalPrepared; + request: ( + args: T, + request?: ApprovalRequest, + ) => Promise | ApprovalDecision; summary?: string | ((args: T) => string); stateKeys?: Partial; }; @@ -124,8 +147,8 @@ export function defineWorkerAction(definition: ActionDefinition): Capabili }); } -export function defineApprovalAction( - definition: ApprovalActionDefinition, +export function defineApprovalAction( + definition: ApprovalActionDefinition, ): CapabilityDefinition { const stateKeys = approvalStateKeys(definition.name, definition.approval.stateKeys); const approvedHandler = definition.handler; @@ -135,18 +158,23 @@ export function defineApprovalAction( ...definition.surface, authority: 'approval-gated', }, - stateShape: `${formatStateShape(definition.stateShape)} & {${stateKeys.pending}: boolean, ${stateKeys.approved}: boolean, ${stateKeys.denied}: boolean, ${stateKeys.error}: string | null}`, + stateShape: `${formatStateShape(definition.stateShape)} & {${stateKeys.requestId}: string | null, ${stateKeys.pending}: boolean, ${stateKeys.approved}: boolean, ${stateKeys.denied}: boolean, ${stateKeys.error}: string | null}`, handler: async (ctx) => { - ctx.push({ - [stateKeys.pending]: true, - [stateKeys.approved]: false, - [stateKeys.denied]: false, - [stateKeys.error]: null, - }); + const requestId = nextApprovalRequestId(definition.name); try { - const decision = normalizeApprovalDecision(await definition.approval.request(ctx.args)); + const prepared = await prepareApprovalRequest(definition, ctx.args); + const request = createApprovalRequest(definition.name, ctx.args, requestId, prepared); + ctx.push({ + [stateKeys.requestId]: request.id, + [stateKeys.pending]: true, + [stateKeys.approved]: false, + [stateKeys.denied]: false, + [stateKeys.error]: null, + }); + const decision = normalizeApprovalDecision(await definition.approval.request(ctx.args, request)); if (decision.status !== 'approved') { ctx.push({ + [stateKeys.requestId]: request.id, [stateKeys.pending]: false, [stateKeys.approved]: false, [stateKeys.denied]: true, @@ -155,15 +183,17 @@ export function defineApprovalAction( return; } ctx.push({ + [stateKeys.requestId]: request.id, [stateKeys.pending]: false, [stateKeys.approved]: true, [stateKeys.denied]: false, [stateKeys.error]: null, }); - await approvedHandler(ctx); + await approvedHandler({ ...ctx, approval: request }); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.push({ + [stateKeys.requestId]: requestId, [stateKeys.pending]: false, [stateKeys.approved]: false, [stateKeys.denied]: false, @@ -415,6 +445,7 @@ function normalizeSurfaceForKind( function approvalStateKeys(name: string, partial: Partial | undefined): ApprovalStateKeys { return { + requestId: partial?.requestId ?? `${name}ApprovalRequestId`, pending: partial?.pending ?? `${name}ApprovalPending`, approved: partial?.approved ?? `${name}ApprovalApproved`, denied: partial?.denied ?? `${name}ApprovalDenied`, @@ -422,6 +453,54 @@ function approvalStateKeys(name: string, partial: Partial | u }; } +let approvalRequestSeq = 0; + +function nextApprovalRequestId(name: string): string { + approvalRequestSeq += 1; + const safeName = name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'approval'; + return `${safeName}-${Date.now().toString(36)}-${approvalRequestSeq.toString(36)}`; +} + +async function prepareApprovalRequest( + definition: ApprovalActionDefinition, + args: T, +): Promise> { + if (definition.approval.prepare) return definition.approval.prepare(args); + return { + summary: approvalSummary(definition.approval.summary, args, definition.name), + plan: args as unknown as Plan, + }; +} + +function approvalSummary( + summary: ApprovalActionDefinition['approval']['summary'], + args: T, + name: string, +): string { + if (typeof summary === 'function') return summary(args); + if (summary) return summary; + return `Approve ${name}`; +} + +function createApprovalRequest( + capability: string, + args: T, + id: string, + prepared: ApprovalPrepared, +): ApprovalRequest { + const request: ApprovalRequest = { + id, + capability, + args, + summary: prepared.summary, + plan: prepared.plan, + status: 'pending', + }; + if (prepared.details !== undefined) request.details = prepared.details; + if (prepared.expiresAt !== undefined) request.expiresAt = prepared.expiresAt; + return request; +} + function normalizeApprovalDecision(decision: ApprovalDecision): { status: 'approved' | 'denied'; reason?: string } { if (decision === 'approved' || decision === 'denied') return { status: decision }; return decision; diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts index 8109cb8..8d6df04 100644 --- a/packages/host/src/index.ts +++ b/packages/host/src/index.ts @@ -21,6 +21,8 @@ export type { ActionDefinition, ApprovalActionDefinition, ApprovalDecision, + ApprovalPrepared, + ApprovalRequest, ApprovalStateKeys, CapabilityDefinition, CapabilityRegistry, diff --git a/packages/host/src/policy-engine.ts b/packages/host/src/policy-engine.ts index 48e5ab9..729a79b 100644 --- a/packages/host/src/policy-engine.ts +++ b/packages/host/src/policy-engine.ts @@ -6,6 +6,7 @@ import type { EventStore } from '@summon-internal/devtools'; import type { ZodType } from 'zod'; +import type { ApprovalRequest } from './capability-registry.js'; export interface IntentContext> { /** @@ -17,6 +18,12 @@ export interface IntentContext> { args: T; /** Merge a patch into the current state. The full merged state is pushed to the sandbox. */ push: (patch: Record) => void; + /** + * Present only for approved approval-gated actions. The host prepared this + * request before the user approved it, so handlers can execute the frozen + * plan instead of recomputing from generated args. + */ + approval?: ApprovalRequest; } export type IntentHandler> = ( diff --git a/packages/host/src/policy.ts b/packages/host/src/policy.ts index 60e8f14..00b9995 100644 --- a/packages/host/src/policy.ts +++ b/packages/host/src/policy.ts @@ -19,6 +19,8 @@ export type { ActionDefinition, ApprovalActionDefinition, ApprovalDecision, + ApprovalPrepared, + ApprovalRequest, ApprovalStateKeys, CapabilityDefinition, CapabilityRegistry, diff --git a/packages/host/test/capability-registry.test.ts b/packages/host/test/capability-registry.test.ts index e7f536a..f4eab5a 100644 --- a/packages/host/test/capability-registry.test.ts +++ b/packages/host/test/capability-registry.test.ts @@ -548,6 +548,7 @@ test('approval action runs approved handler only after approval', async () => { const contract = registry.toContract(); assert.equal(contract.validationCapabilities[0]?.surface?.authority, 'approval-gated'); + assert.match(contract.pack.intents[0]?.stateShape ?? '', /publishApprovalRequestId: string \| null/); const policy = new PolicyEngine({ handlers: registry.toPolicyHandlers(), @@ -560,6 +561,8 @@ test('approval action runs approved handler only after approval', async () => { assert.equal(approvedCalls, 0); assert.equal(states.at(-1)?.publishApprovalDenied, true); assert.equal(states.at(-1)?.publishApprovalError, 'Not this one'); + assert.equal(states.at(-2)?.publishApprovalPending, true); + assert.equal(typeof states.at(-2)?.publishApprovalRequestId, 'string'); await policy.dispatch('publish', { title: 'ok' }); assert.equal(approvedCalls, 1); @@ -568,6 +571,7 @@ test('approval action runs approved handler only after approval', async () => { }); test('approval action rejects invalid args before requesting approval', async () => { + let prepareCalls = 0; let approvalCalls = 0; let approvedCalls = 0; const registry = createCapabilityRegistry([ @@ -577,6 +581,10 @@ test('approval action rejects invalid args before requesting approval', async () argsSchema: z.object({ title: z.string() }), stateShape: '{published: boolean}', approval: { + prepare: ({ title }) => { + prepareCalls += 1; + return { summary: `Publish ${title}`, plan: { title } }; + }, request: () => { approvalCalls += 1; return 'approved'; @@ -596,11 +604,79 @@ test('approval action rejects invalid args before requesting approval', async () }); await policy.dispatch('publish', { title: 42 }); + assert.equal(prepareCalls, 0); assert.equal(approvalCalls, 0); assert.equal(approvedCalls, 0); assert.ok(errors[0] instanceof IntentArgsError); }); +test('approval action prepares a frozen request for host approval and approved handler', async () => { + let prepareCalls = 0; + let seenRequest: + | { + id: string; + capability: string; + summary: string; + details?: unknown; + plan: unknown; + status: string; + expiresAt?: string; + } + | undefined; + let handlerPlan: unknown; + const states: Record[] = []; + const registry = createCapabilityRegistry([ + defineApprovalAction({ + name: 'publish', + description: 'Publish after approval.', + argsSchema: z.object({ title: z.string() }), + stateShape: '{publishedTitle: string | null}', + approval: { + prepare: ({ title }) => { + prepareCalls += 1; + return { + summary: `Publish "${title}"`, + details: { channel: 'demo-updates' }, + plan: { operation: 'publish', title: title.toUpperCase() }, + expiresAt: '2026-06-10T12:00:00.000Z', + }; + }, + request: (_args, request) => { + seenRequest = request; + return 'approved'; + }, + }, + handler: ({ approval, push }) => { + handlerPlan = approval?.plan; + const plan = approval?.plan as { title: string }; + push({ publishedTitle: plan.title }); + }, + }), + ]); + + const policy = new PolicyEngine({ + handlers: registry.toPolicyHandlers(), + onStateChange: (state) => { + states.push(state); + }, + }); + + await policy.dispatch('publish', { title: 'launch note' }); + + assert.equal(prepareCalls, 1); + assert.ok(seenRequest); + assert.equal(seenRequest.capability, 'publish'); + assert.equal(seenRequest.summary, 'Publish "launch note"'); + assert.deepEqual(seenRequest.details, { channel: 'demo-updates' }); + assert.deepEqual(seenRequest.plan, { operation: 'publish', title: 'LAUNCH NOTE' }); + assert.equal(seenRequest.status, 'pending'); + assert.equal(seenRequest.expiresAt, '2026-06-10T12:00:00.000Z'); + assert.equal(states[0]?.publishApprovalPending, true); + assert.equal(states[0]?.publishApprovalRequestId, seenRequest.id); + assert.deepEqual(handlerPlan, { operation: 'publish', title: 'LAUNCH NOTE' }); + assert.equal(states.at(-1)?.publishedTitle, 'LAUNCH NOTE'); +}); + test('surface envelope serializes replay metadata', () => { const envelope = createSurfaceEnvelope({ prompt: 'compare options', diff --git a/packages/summon/src/index.ts b/packages/summon/src/index.ts index 657cc25..d1cce02 100644 --- a/packages/summon/src/index.ts +++ b/packages/summon/src/index.ts @@ -25,6 +25,8 @@ export type { ActionDefinition, ApprovalActionDefinition, ApprovalDecision, + ApprovalPrepared, + ApprovalRequest, ApprovalStateKeys, CapabilityDefinition, CapabilityRegistry, diff --git a/packages/summon/src/policy.ts b/packages/summon/src/policy.ts index e7365b7..bb31a16 100644 --- a/packages/summon/src/policy.ts +++ b/packages/summon/src/policy.ts @@ -4,6 +4,8 @@ export { defineIntent, } from '@summon-internal/host/policy'; export type { + ApprovalPrepared, + ApprovalRequest, IntentContext, IntentEntry, IntentHandler,