mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Adds the request/refusal shapes every `agentSession.*` method speaks, the four-field idempotency envelope and its lease admission, the item-key inverse an upsert needs, and the `agent-session.structured.v1` capability. The protocol version stays at 3: the surface is additive, and bumping it would strand every paired device on the current release rather than degrade one feature.
152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
// Admission for one mutating `agentSession.*` call.
|
|
//
|
|
// The rules themselves live in the durable ledger and the lease adjudicator;
|
|
// this is only the fixed order they are applied in, plus the payload
|
|
// fingerprint both peers derive from the same request fields. Nothing here
|
|
// re-derives who may write — that answer comes from
|
|
// `agentSessionLeaseAdmitsWriter` alone.
|
|
|
|
import { createHash } from 'node:crypto'
|
|
import type {
|
|
AgentSessionOperationDecision,
|
|
AgentSessionOperationRow
|
|
} from './agent-session-operation-ledger'
|
|
import {
|
|
agentSessionLeaseAdmitsWriter,
|
|
isAgentSessionFenceCurrent
|
|
} from './agent-session-lease-adjudication'
|
|
import type { AgentSessionLease } from './agent-session-record'
|
|
import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire'
|
|
|
|
/**
|
|
* Stable digest over the fields that define what this call DOES. Keys are
|
|
* emitted in sorted order at every depth so two peers serializing the same
|
|
* request in different property order agree, and an undefined field is dropped
|
|
* rather than hashed as present-but-empty.
|
|
*/
|
|
export function computeAgentSessionPayloadFingerprint(input: {
|
|
method: string
|
|
sessionId: string
|
|
fields: Record<string, unknown>
|
|
}): string {
|
|
const canonical = canonicalize({
|
|
method: input.method,
|
|
sessionId: input.sessionId,
|
|
fields: input.fields
|
|
})
|
|
return createHash('sha256').update(canonical).digest('hex')
|
|
}
|
|
|
|
function canonicalize(value: unknown): string {
|
|
if (value === null || typeof value !== 'object') {
|
|
return JSON.stringify(value ?? null)
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(canonicalize).join(',')}]`
|
|
}
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.filter(([, entry]) => entry !== undefined)
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}`
|
|
}
|
|
|
|
/**
|
|
* A retry whose payload changed is a different call wearing the same id.
|
|
* Checked BEFORE the ledger is consulted, so a refused call never leaves an
|
|
* admitted row that a later honest retry would replay as already-done.
|
|
*/
|
|
export function agentSessionFingerprintConflict(
|
|
envelope: AgentSessionMutationEnvelope,
|
|
hostFingerprint: string
|
|
): AgentSessionWireRefusal | null {
|
|
return envelope.payloadFingerprint === hostFingerprint
|
|
? null
|
|
: {
|
|
code: 'agent_session_operation_conflict',
|
|
message:
|
|
'The payload does not match the fingerprint the client declared for this operation.'
|
|
}
|
|
}
|
|
|
|
export type AgentSessionMutationAdmission =
|
|
| { decision: 'admit'; row: AgentSessionOperationRow }
|
|
/** The recorded outcome answers this call; do not run the effect again. */
|
|
| { decision: 'replay'; row: AgentSessionOperationRow }
|
|
| { decision: 'refused'; refusal: AgentSessionWireRefusal }
|
|
|
|
/**
|
|
* Fixed order: fingerprint agreement, then the ledger (so a retry replays
|
|
* before anything else can refuse it), then the lease, then the fence. Putting
|
|
* the ledger ahead of the fence is deliberate — a retry that crossed an owner
|
|
* change must still return its recorded answer instead of a stale-checkpoint
|
|
* refusal the client would then resend as a second effect.
|
|
*/
|
|
export function admitAgentSessionMutation(input: {
|
|
envelope: AgentSessionMutationEnvelope
|
|
/** Fingerprint the host computed from the request it actually received. */
|
|
hostFingerprint: string
|
|
/** Decision from the durable ledger, evaluated under `hostFingerprint`. */
|
|
ledger: AgentSessionOperationDecision
|
|
lease: AgentSessionLease
|
|
}): AgentSessionMutationAdmission {
|
|
const { envelope, lease, ledger } = input
|
|
const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint)
|
|
if (mismatch) {
|
|
return { decision: 'refused', refusal: mismatch }
|
|
}
|
|
if (ledger.decision === 'refused') {
|
|
return {
|
|
decision: 'refused',
|
|
refusal: {
|
|
code: ledger.code,
|
|
message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.`
|
|
}
|
|
}
|
|
}
|
|
if (ledger.decision === 'replay') {
|
|
return { decision: 'replay', row: ledger.row }
|
|
}
|
|
const leaseRefusal = refuseUnlessWriterAdmitted(lease)
|
|
if (leaseRefusal) {
|
|
return { decision: 'refused', refusal: leaseRefusal }
|
|
}
|
|
if (
|
|
envelope.expectedRuntimeFence === null ||
|
|
!isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence)
|
|
) {
|
|
return {
|
|
decision: 'refused',
|
|
refusal: {
|
|
code: 'agent_session_checkpoint_stale',
|
|
message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`,
|
|
currentFence: lease.runtimeFence
|
|
}
|
|
}
|
|
}
|
|
return { decision: 'admit', row: ledger.row }
|
|
}
|
|
|
|
/** Why the single admission oracle said no, mapped to what the client can do
|
|
* about it. The predicate itself is never re-implemented here. */
|
|
function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null {
|
|
if (agentSessionLeaseAdmitsWriter(lease)) {
|
|
return null
|
|
}
|
|
if (lease.unreconciled) {
|
|
return {
|
|
code: 'execution_owner_reconciling',
|
|
message: 'This host has not yet adjudicated the session lease.'
|
|
}
|
|
}
|
|
if (lease.handoffStage !== null) {
|
|
return {
|
|
code: 'agent_session_conflict',
|
|
message: `The session is mid-handoff (${lease.handoffStage}).`
|
|
}
|
|
}
|
|
return {
|
|
code: 'agent_session_ownership_unknown',
|
|
message: 'The session has no live owner to accept writes.'
|
|
}
|
|
}
|