feat(host): approval pending registry on the api gateway
The approval/request waterfall answerer fills the slot the gateway reserved
('registry absent in this minimal version'): an ask through ctx.approval
becomes an answerable approval/requested mux frame with a stable rpcId,
mux open replays still-pending frames (refresh recovery), respond routes
approvals first by the echoed rpcId and cross-checks the payload's audit
correlation, the ask's abort signal withdraws with a broadcast cancelled,
and approval/resolved settles every subscriber. The answerer pairs each ask
with its approval/asked audit event by scanning for the newest undecided,
unclaimed id (callId-matched when the ask names a call); asks that bypassed
the audit path delegate to the fail-closed default.
The child activates only when ctx.approval is composed; the 275-line spec
carries over verbatim at the gateway's new home.
This commit is contained in:
@@ -33,6 +33,12 @@ import type {} from '@deepseek-ai/dsh-session-projection'
|
|||||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||||
import type {} from '@deepseek-ai/dsh-commands'
|
import type {} from '@deepseek-ai/dsh-commands'
|
||||||
import type {} from '@deepseek-ai/dsh-skill'
|
import type {} from '@deepseek-ai/dsh-skill'
|
||||||
|
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||||
|
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||||
|
// Side-effect type import: resolves the `approval/request` waterfall and
|
||||||
|
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||||
|
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||||
|
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
||||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||||
import { RpcId } from './api/rpc.ts'
|
import { RpcId } from './api/rpc.ts'
|
||||||
@@ -123,9 +129,9 @@ class FrameQueue<F> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
|
* Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
|
||||||
* for answerable frames belong to the approval/question registry, absent in
|
* frames — approval/question requested — mint their stable id in their
|
||||||
* this minimal version).
|
* pending registries instead).
|
||||||
*/
|
*/
|
||||||
function frame<F>(payload: F): RpcRequest<F> {
|
function frame<F>(payload: F): RpcRequest<F> {
|
||||||
return { rpcId: RpcId(randomUUID()), payload }
|
return { rpcId: RpcId(randomUUID()), payload }
|
||||||
@@ -194,6 +200,36 @@ export interface ApiProxyDefaults {
|
|||||||
|
|
||||||
/** The tool/call payload fields the presenter path reads. */
|
/** The tool/call payload fields the presenter path reads. */
|
||||||
interface ToolCallData { callId: string; name: string; arguments: string }
|
interface ToolCallData { callId: string; name: string; arguments: string }
|
||||||
|
/**
|
||||||
|
* One outstanding approval question: the stable server-request id, the frame
|
||||||
|
* material replayed to late mux subscribers, and the resolver that settles the
|
||||||
|
* answerer's promise back into `ctx.approval`.
|
||||||
|
*/
|
||||||
|
interface PendingApproval {
|
||||||
|
rpcId: RpcId
|
||||||
|
sessionId: SessionId
|
||||||
|
approvalId: ApprovalRequestId
|
||||||
|
toolName: string
|
||||||
|
callId?: CallId
|
||||||
|
reason?: string
|
||||||
|
resolve(outcome: ApprovalOutcome): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
|
||||||
|
function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
|
||||||
|
return {
|
||||||
|
rpcId: pending.rpcId,
|
||||||
|
payload: {
|
||||||
|
type: 'approval/requested',
|
||||||
|
sessionId: pending.sessionId,
|
||||||
|
approvalId: pending.approvalId,
|
||||||
|
toolName: pending.toolName,
|
||||||
|
...pending.callId === undefined ? {} : { callId: pending.callId },
|
||||||
|
...pending.reason === undefined ? {} : { reason: pending.reason },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One host-owned question wait, addressed by the stable server-request id. */
|
/** One host-owned question wait, addressed by the stable server-request id. */
|
||||||
interface PendingQuestion {
|
interface PendingQuestion {
|
||||||
rpcId: RpcId
|
rpcId: RpcId
|
||||||
@@ -371,6 +407,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
/** Serializes path ownership checks with record creation across spellings. */
|
/** Serializes path ownership checks with record creation across spellings. */
|
||||||
let workspaceCreationChain = Promise.resolve()
|
let workspaceCreationChain = Promise.resolve()
|
||||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||||
|
const pendingApprovals = new Map<RpcId, PendingApproval>()
|
||||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -519,6 +556,73 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
}, 'api-proxy: user-interaction provider')
|
}, 'api-proxy: user-interaction provider')
|
||||||
|
|
||||||
|
// --- Approval pending registry ------------------------------------------
|
||||||
|
// The proxy is the approval channel for every agent this host owns: an ask
|
||||||
|
// through `ctx.approval` becomes an answerable server-request on the mux
|
||||||
|
// stream (stable rpcId), settled by POST /api/respond. The entry survives
|
||||||
|
// client disconnects — mux-open replays still-pending requested frames with
|
||||||
|
// the same rpcId (the refresh-recovery baseline) — and withdraws on the
|
||||||
|
// ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
|
||||||
|
if (ctx.get('approval') !== undefined) {
|
||||||
|
ctx.on('approval/request', (req, next) => {
|
||||||
|
// The audit pair `approval/asked` is already appended by the service
|
||||||
|
// before dispatch, but dispatch rides a microtask: parallel tool calls
|
||||||
|
// can append several asked events before any answerer runs. THIS
|
||||||
|
// request's event is therefore the newest asked event that is still
|
||||||
|
// undecided, unclaimed by another pending entry, and — when the ask
|
||||||
|
// names a call — carries the same callId.
|
||||||
|
const events = req.agent.session.events
|
||||||
|
const claimed = new Set<ApprovalRequestId>()
|
||||||
|
for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
|
||||||
|
const decided = new Set<ApprovalRequestId>()
|
||||||
|
let approvalId: ApprovalRequestId | undefined
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i] as SessionEvent
|
||||||
|
if (event.type === 'approval/decided') {
|
||||||
|
decided.add(event.data.id)
|
||||||
|
} else if (event.type === 'approval/asked') {
|
||||||
|
if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
|
||||||
|
if (req.callId !== undefined && event.data.callId !== req.callId) continue
|
||||||
|
approvalId = event.data.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No asked event means the request bypassed the service's audit path —
|
||||||
|
// not this channel's question; delegate to the fail-closed default.
|
||||||
|
if (approvalId === undefined) return next()
|
||||||
|
const id = approvalId
|
||||||
|
return new Promise<ApprovalOutcome>((resolve) => {
|
||||||
|
const settle = (outcome: ApprovalOutcome): void => {
|
||||||
|
/* v8 ignore next 3 -- defensive double-settle guard: respond() routes
|
||||||
|
through the pending table (a settled id is not-pending before it can
|
||||||
|
re-settle) and the first settle removes the abort listener, so no
|
||||||
|
reachable path settles twice; kept against future settle callers. */
|
||||||
|
if (!pendingApprovals.delete(pending.rpcId)) return
|
||||||
|
req.signal?.removeEventListener('abort', onAbort)
|
||||||
|
broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
|
||||||
|
// A cancelled ask was already settled by the service's own signal
|
||||||
|
// race, which discards this late resolution; resolving is a no-op
|
||||||
|
// there and keeps this promise from dangling forever.
|
||||||
|
resolve(outcome)
|
||||||
|
}
|
||||||
|
const onAbort = (): void => { settle('cancelled') }
|
||||||
|
const pending: PendingApproval = {
|
||||||
|
rpcId: RpcId(randomUUID()),
|
||||||
|
sessionId: req.agent.session.id,
|
||||||
|
approvalId: id,
|
||||||
|
toolName: req.toolName,
|
||||||
|
...req.callId === undefined ? {} : { callId: req.callId },
|
||||||
|
...req.reason === undefined ? {} : { reason: req.reason },
|
||||||
|
resolve: settle,
|
||||||
|
}
|
||||||
|
pendingApprovals.set(pending.rpcId, pending)
|
||||||
|
req.signal?.addEventListener('abort', onAbort, { once: true })
|
||||||
|
const envelope = requestedFrame(pending)
|
||||||
|
for (const queue of muxQueues) queue.push(envelope)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
||||||
* log without a cwd (pre-release stance: not served, no compatibility), is
|
* log without a cwd (pre-release stance: not served, no compatibility), is
|
||||||
@@ -1150,6 +1254,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Refresh recovery: still-pending approval questions replay with their
|
||||||
|
// stable rpcId so a reconnecting client can still answer them.
|
||||||
|
for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
|
||||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||||
// in arrival order per session; a reconnecting client rebuilds its
|
// in arrival order per session; a reconnecting client rebuilds its
|
||||||
// queue view from these alone.
|
// queue view from these alone.
|
||||||
@@ -1267,6 +1374,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
|
|
||||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
|
// Route by the echoed rpcId (the wire correlation): approvals first,
|
||||||
|
// then questions — the two registries share one id space of UUIDs.
|
||||||
|
const approval = pendingApprovals.get(message.rpcId)
|
||||||
|
if (approval !== undefined) {
|
||||||
|
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
|
||||||
|
// The payload's audit correlation must match the entry the rpcId routed
|
||||||
|
// to — a mismatched answer is malformed, not merely late.
|
||||||
|
if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
approval.resolve(parsed.data.outcome)
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
|
}
|
||||||
const pending = pendingQuestions.get(message.rpcId)
|
const pending = pendingQuestions.get(message.rpcId)
|
||||||
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||||
if (!message.result.ok) {
|
if (!message.result.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user