diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing-uds.test.ts new file mode 100644 index 0000000000..057c1e4751 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing-uds.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { connectRuntimeHost } from '@maka/runtime-host/client'; +import { + HOST_OPERATION_SPECS, + RUNTIME_HOST_PROTOCOL_VERSION, + type EffectivePricingEntry, + type OperationKey, +} from '@maka/runtime-host/protocol'; +import { + RuntimeHostKernel, + type RuntimeHostComposition, +} from '@maka/runtime-host/server'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + DesktopRuntimeHostClient, + DesktopRuntimeHostClientError, +} from '../runtime-host-client.js'; + +test('drives the Desktop Pricing adapter through a real Runtime Host connection', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-desktop-pricing-client-')); + let host: RuntimeHostKernel | undefined; + try { + const capability = await resolveStorageRoot({ path: base, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + let revision = 1; + let mutationRequests = 0; + let entries: EffectivePricingEntry[] = [ + builtin('provider:first', 1), + builtin('provider:second', 2), + ]; + host = await RuntimeHostKernel.start({ + owner, + idleGraceMs: 10_000, + compositionFactory: async () => ({ + handlers: handlers({ + 'pricing.query': async (input) => { + if (input.kind === 'continue' && input.revision !== revision) { + return { + ok: true, + result: { + kind: 'revision_changed', + expectedRevision: input.revision, + actualRevision: revision, + }, + }; + } + const offset = input.kind === 'start' ? 0 : input.offset; + const pageEntries = entries.slice(offset, offset + 1); + const nextOffset = offset + pageEntries.length; + return { + ok: true, + result: { + kind: 'page', + revision, + offset, + entries: pageEntries, + nextOffset: nextOffset < entries.length ? nextOffset : null, + }, + }; + }, + 'pricing.mutate': async (input) => { + mutationRequests += 1; + if (input.expectedRevision !== revision) { + return { + ok: true, + result: { + kind: 'revision_conflict', + expectedRevision: input.expectedRevision, + actualRevision: revision, + }, + }; + } + assert.equal(input.mutation.kind, 'upsert'); + if (input.mutation.kind !== 'upsert') throw new Error('Expected an upsert'); + entries = [entries[0]!, { + pricing: input.mutation.pricing, + source: 'custom', + resetEffect: 'restore_builtin', + }]; + revision += 1; + return { ok: true, result: { kind: 'committed', revision } }; + }, + }), + beginDrain() {}, + async recover() {}, + async close() {}, + }), + }); + const connected = await connectRuntimeHost({ + rootPath: base, + surface: 'desktop', + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') throw new Error('Desktop did not connect to Runtime Host'); + const client = new DesktopRuntimeHostClient(connected.connection); + + const initial = await client.loadPricingSnapshot(); + assert.equal(initial.hostEpoch, connected.connection.hostEpoch); + assert.equal(initial.connectionId, connected.connection.connectionId); + assert.deepEqual(initial.entries, entries); + + await client.close(); + const reconnected = await connectRuntimeHost({ + rootPath: base, + surface: 'desktop', + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + assert.equal(reconnected.kind, 'connected'); + if (reconnected.kind !== 'connected') { + throw new Error('Desktop did not reconnect to Runtime Host'); + } + assert.equal(reconnected.connection.hostEpoch, connected.connection.hostEpoch); + assert.notEqual(reconnected.connection.connectionId, connected.connection.connectionId); + const reconnectedClient = new DesktopRuntimeHostClient(reconnected.connection); + + const override = pricing('provider:second', 4); + await assert.rejects( + () => + reconnectedClient.applyPricingMutation({ + base: initial, + mutation: { kind: 'upsert', pricing: override }, + }), + (error: unknown) => + error instanceof DesktopRuntimeHostClientError && + error.code === 'pricing_snapshot_stale', + ); + assert.equal(mutationRequests, 0); + + const reloaded = await reconnectedClient.loadPricingSnapshot(); + assert.deepEqual( + await reconnectedClient.applyPricingMutation({ + base: reloaded, + mutation: { kind: 'upsert', pricing: override }, + }), + { + kind: 'saved', + disposition: 'committed', + snapshot: { + hostEpoch: reconnected.connection.hostEpoch, + connectionId: reconnected.connection.connectionId, + revision: 2, + entries: [builtin('provider:first', 1), custom(override)], + }, + }, + ); + assert.equal(mutationRequests, 1); + + await reconnectedClient.close(); + } finally { + await host?.close().catch(() => undefined); + await rm(base, { recursive: true, force: true }); + } +}); + +type TestHandlers = Partial; + +function handlers(overrides: TestHandlers): RuntimeHostComposition['handlers'] { + const unavailable = Object.fromEntries( + (Object.keys(HOST_OPERATION_SPECS) as OperationKey[]) + .filter((operation) => operation !== 'host.status') + .map((operation) => [ + operation, + async () => ({ + ok: false, + error: { + code: 'operation_unavailable', + message: `${operation} is unavailable in the Desktop Pricing adapter fixture`, + }, + }), + ]), + ); + return { ...unavailable, ...overrides } as RuntimeHostComposition['handlers']; +} + +function builtin(modelKey: string, inputUsdPer1M: number): EffectivePricingEntry { + return { pricing: pricing(modelKey, inputUsdPer1M), source: 'builtin' }; +} + +function custom(value: ReturnType): EffectivePricingEntry { + return { pricing: value, source: 'custom', resetEffect: 'restore_builtin' }; +} + +function pricing(modelKey: string, inputUsdPer1M: number) { + return { + modelKey, + inputUsdPer1M, + outputUsdPer1M: inputUsdPer1M * 2, + cacheReadUsdPer1M: inputUsdPer1M / 2, + cacheWriteUsdPer1M: inputUsdPer1M * 1.5, + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts new file mode 100644 index 0000000000..388143be74 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + RuntimeHostOperationError, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; +import type { + EffectivePricingEntry, + OperationInput, + OperationKey, +} from '@maka/runtime-host/protocol'; +import { + DesktopRuntimeHostClient, + DesktopRuntimeHostClientError, + type DesktopPricingSnapshot, +} from '../runtime-host-client.js'; + +test('restarts a paginated Pricing read instead of mixing revisions', async () => { + const stale = builtin('provider:stale', 1); + const freshOne = builtin('provider:fresh-1', 2); + const freshTwo = custom('provider:fresh-2', 3, 'become_unpriced'); + const { client, requests } = clientWithResponses([ + page(1, 0, [stale], 1), + { kind: 'revision_changed', expectedRevision: 1, actualRevision: 2 }, + page(2, 0, [freshOne], 1), + page(2, 1, [freshTwo], null), + ]); + + assert.deepEqual(await client.loadPricingSnapshot(), { + hostEpoch: 'host-current', + connectionId: 'connection-current', + revision: 2, + entries: [freshOne, freshTwo], + }); + assert.deepEqual(requests, [ + { operation: 'pricing.query', input: { kind: 'start' } }, + { + operation: 'pricing.query', + input: { kind: 'continue', revision: 1, offset: 1 }, + }, + { operation: 'pricing.query', input: { kind: 'start' } }, + { + operation: 'pricing.query', + input: { kind: 'continue', revision: 2, offset: 1 }, + }, + ]); +}); + +test('fails after bounded Pricing snapshot restarts under continuous churn', async () => { + const responses = Array.from({ length: 3 }).flatMap((_, index) => [ + page(index, 0, [builtin(`provider:model-${index}`, index + 1)], 1), + { kind: 'revision_changed', expectedRevision: index, actualRevision: index + 1 }, + ]); + const { client, requests } = clientWithResponses(responses); + + await assert.rejects( + () => client.loadPricingSnapshot(), + (error: unknown) => + error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable', + ); + assert.equal(requests.filter(({ operation }) => operation === 'pricing.query').length, 6); +}); + +test('rejects duplicate or non-canonical keys split across Pricing pages', async () => { + const { client } = clientWithResponses([ + page(1, 0, [builtin('provider:second', 2)], 1), + page(1, 1, [builtin('provider:first', 1)], null), + ]); + + await assert.rejects( + () => client.loadPricingSnapshot(), + (error: unknown) => + error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable', + ); +}); + +test('rejects a Pricing mutation from another Host Epoch before dispatch', async () => { + const { client, requests } = clientWithResponses([]); + + await assert.rejects( + () => + client.applyPricingMutation({ + base: snapshot('host-replaced', 4, [builtin('provider:model', 1)]), + mutation: { kind: 'delete', modelKey: 'provider:model' }, + }), + (error: unknown) => + error instanceof DesktopRuntimeHostClientError && + error.code === 'pricing_snapshot_stale', + ); + assert.deepEqual(requests, []); +}); + +test('rejects a Pricing mutation from a previous connection with the same Host Epoch', async () => { + const { client, requests } = clientWithResponses([]); + + await assert.rejects( + () => + client.applyPricingMutation({ + base: snapshot( + 'host-current', + 4, + [builtin('provider:model', 1)], + 'connection-previous', + ), + mutation: { kind: 'delete', modelKey: 'provider:model' }, + }), + (error: unknown) => + error instanceof DesktopRuntimeHostClientError && + error.code === 'pricing_snapshot_stale', + ); + assert.deepEqual(requests, []); +}); + +test('reloads authority after a committed Pricing mutation', async () => { + const updated = custom('provider:model', 2, 'restore_builtin'); + const { client, requests } = clientWithResponses([ + { kind: 'committed', revision: 5 }, + page(5, 0, [updated], null), + ]); + const base = snapshot('host-current', 4, [builtin('provider:model', 1)]); + + assert.deepEqual( + await client.applyPricingMutation({ + base, + mutation: { kind: 'upsert', pricing: updated.pricing }, + }), + { + kind: 'saved', + disposition: 'committed', + snapshot: snapshot('host-current', 5, [updated]), + }, + ); + assert.deepEqual(requests.map(({ operation }) => operation), [ + 'pricing.mutate', + 'pricing.query', + ]); +}); + +test('keeps a known save outcome distinct when the authoritative reload fails', async () => { + const { client } = clientWithResponses([ + { kind: 'unchanged', revision: 4 }, + new RuntimeHostOperationError('pricing.query', 'host_draining', 'Host is draining'), + ]); + const base = snapshot('host-current', 4, [builtin('provider:model', 1)]); + + assert.deepEqual( + await client.applyPricingMutation({ + base, + mutation: { kind: 'delete', modelKey: 'provider:model' }, + }), + { kind: 'saved_refresh_failed', disposition: 'unchanged' }, + ); +}); + +test('propagates a typed rejected mutation without classifying it as uncertain', async () => { + const rejected = new RuntimeHostOperationError( + 'pricing.mutate', + 'invalid_request', + 'Pricing mutation is invalid', + ); + const { client, requests } = clientWithResponses([rejected]); + + await assert.rejects( + () => + client.applyPricingMutation({ + base: snapshot('host-current', 4, [builtin('provider:model', 1)]), + mutation: { kind: 'delete', modelKey: 'provider:model' }, + }), + (error: unknown) => error === rejected, + ); + assert.deepEqual(requests.map(({ operation }) => operation), ['pricing.mutate']); +}); + +test('does not replay a conflicting mutation and reports when authority already matches', async () => { + const intended = custom('provider:model', 4, 'restore_builtin'); + const { client, requests } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 2, actualRevision: 3 }, + page(3, 0, [intended], null), + ]); + + assert.deepEqual( + await client.applyPricingMutation({ + base: snapshot('host-current', 2, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: intended.pricing }, + }), + { + kind: 'synchronized', + reason: 'revision_conflict', + snapshot: snapshot('host-current', 3, [intended]), + }, + ); + assert.equal(requests.filter(({ operation }) => operation === 'pricing.mutate').length, 1); +}); + +test('requires review when a conflicting write differs from fresh authority', async () => { + const intended = custom('provider:model', 4, 'restore_builtin'); + const current = custom('provider:model', 5, 'restore_builtin'); + const { client } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 2, actualRevision: 3 }, + page(3, 0, [current], null), + ]); + + assert.deepEqual( + await client.applyPricingMutation({ + base: snapshot('host-current', 2, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: intended.pricing }, + }), + { + kind: 'review_required', + reason: 'revision_conflict', + snapshot: snapshot('host-current', 3, [current]), + }, + ); +}); + +test('reconciles an uncertain mutation without claiming which command committed it', async () => { + const intended = custom('provider:model', 4, 'restore_builtin'); + const { client, requests } = clientWithResponses([ + new RuntimeHostOperationError( + 'pricing.mutate', + 'commit_outcome_unknown', + 'Commit outcome is unknown', + ), + page(3, 0, [intended], null), + ]); + + assert.deepEqual( + await client.applyPricingMutation({ + base: snapshot('host-current', 2, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: intended.pricing }, + }), + { + kind: 'synchronized', + reason: 'outcome_unknown', + snapshot: snapshot('host-current', 3, [intended]), + }, + ); + assert.equal(requests.filter(({ operation }) => operation === 'pricing.mutate').length, 1); +}); + +test('requires review after response loss when fresh authority differs', async () => { + const intended = custom('provider:model', 4, 'restore_builtin'); + const current = custom('provider:model', 5, 'restore_builtin'); + const { client } = clientWithResponses([ + new Error('connection closed before the response arrived'), + page(3, 0, [current], null), + ]); + + assert.deepEqual( + await client.applyPricingMutation({ + base: snapshot('host-current', 2, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: intended.pricing }, + }), + { + kind: 'review_required', + reason: 'outcome_unknown', + snapshot: snapshot('host-current', 3, [current]), + }, + ); +}); + +test('keeps writes blocked when uncertain-outcome reconciliation cannot reload authority', async () => { + const { client } = clientWithResponses([ + new Error('connection closed before the response arrived'), + new Error('replacement Host is not available yet'), + ]); + + assert.deepEqual( + await client.applyPricingMutation({ + base: snapshot('host-current', 2, [builtin('provider:model', 1)]), + mutation: { kind: 'delete', modelKey: 'provider:model' }, + }), + { kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }, + ); +}); + +test('distinguishes reset from Custom-only delete while reconciling', async () => { + const resetBase = custom('provider:reset', 2, 'restore_builtin'); + const deleteBase = custom('provider:delete', 2, 'become_unpriced'); + const { client: resetClient } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + page(2, 0, [builtin('provider:reset', 1)], null), + ]); + const { client: deleteClient } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + page(2, 0, [], null), + ]); + + assert.equal( + ( + await resetClient.applyPricingMutation({ + base: snapshot('host-current', 1, [resetBase]), + mutation: { kind: 'delete', modelKey: 'provider:reset' }, + }) + ).kind, + 'synchronized', + ); + assert.equal( + ( + await deleteClient.applyPricingMutation({ + base: snapshot('host-current', 1, [deleteBase]), + mutation: { kind: 'delete', modelKey: 'provider:delete' }, + }) + ).kind, + 'synchronized', + ); +}); + +test('requires custom provenance when an upsert equals the bundled values', async () => { + const value = builtin('provider:model', 2); + const { client } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + page(2, 0, [value], null), + ]); + + assert.equal( + ( + await client.applyPricingMutation({ + base: snapshot('host-current', 1, [value]), + mutation: { kind: 'upsert', pricing: value.pricing }, + }) + ).kind, + 'review_required', + ); +}); + +test('captures the canonical mutation target before an uncertain request settles', async () => { + const pending = deferred(); + const original = pricing('provider:model', 4); + const { client, requests } = clientWithResponses([ + pending.promise, + page(2, 0, [custom('provider:model', 4, 'restore_builtin')], null), + ]); + const operation = client.applyPricingMutation({ + base: snapshot('host-current', 1, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: original }, + }); + + original.modelKey = 'provider:changed-after-dispatch'; + original.inputUsdPer1M = 99; + pending.reject(new Error('response lost after dispatch')); + + assert.deepEqual(await operation, { + kind: 'synchronized', + reason: 'outcome_unknown', + snapshot: snapshot('host-current', 2, [custom('provider:model', 4, 'restore_builtin')]), + }); + assert.deepEqual(requests[0], { + operation: 'pricing.mutate', + input: { + expectedRevision: 1, + mutation: { kind: 'upsert', pricing: pricing('provider:model', 4) }, + }, + }); +}); + +test('does not collapse an omitted cache rate into an explicit zero during reconciliation', async () => { + const intended = { + modelKey: 'provider:model', + inputUsdPer1M: 1, + outputUsdPer1M: 2, + }; + const current = { + ...intended, + cacheReadUsdPer1M: 0, + }; + const { client } = clientWithResponses([ + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + page(2, 0, [{ pricing: current, source: 'custom', resetEffect: 'restore_builtin' }], null), + ]); + + assert.equal( + ( + await client.applyPricingMutation({ + base: snapshot('host-current', 1, [builtin('provider:model', 1)]), + mutation: { kind: 'upsert', pricing: intended }, + }) + ).kind, + 'review_required', + ); +}); + +interface RecordedRequest { + operation: OperationKey; + input: unknown; +} + +function clientWithResponses(responses: unknown[]): { + client: DesktopRuntimeHostClient; + requests: RecordedRequest[]; +} { + const remaining = [...responses]; + const requests: RecordedRequest[] = []; + const connection = { + hostEpoch: 'host-current', + connectionId: 'connection-current', + request: async (operation: K, input: OperationInput) => { + requests.push({ operation, input }); + if (remaining.length === 0) throw new Error(`Unexpected operation: ${operation}`); + const response = remaining.shift(); + if (response instanceof Error) throw response; + return response; + }, + close: async () => undefined, + } as unknown as RuntimeHostConnection; + return { client: new DesktopRuntimeHostClient(connection), requests }; +} + +function snapshot( + hostEpoch: string, + revision: number, + entries: readonly EffectivePricingEntry[], + connectionId = 'connection-current', +): DesktopPricingSnapshot { + return { hostEpoch, connectionId, revision, entries }; +} + +function page( + revision: number, + offset: number, + entries: readonly EffectivePricingEntry[], + nextOffset: number | null, +) { + return { kind: 'page' as const, revision, offset, entries, nextOffset }; +} + +function builtin(modelKey: string, inputUsdPer1M: number): EffectivePricingEntry { + return { pricing: pricing(modelKey, inputUsdPer1M), source: 'builtin' }; +} + +function custom( + modelKey: string, + inputUsdPer1M: number, + resetEffect: 'restore_builtin' | 'become_unpriced', +): EffectivePricingEntry { + return { pricing: pricing(modelKey, inputUsdPer1M), source: 'custom', resetEffect }; +} + +function pricing(modelKey: string, inputUsdPer1M: number) { + return { + modelKey, + inputUsdPer1M, + outputUsdPer1M: inputUsdPer1M * 2, + cacheReadUsdPer1M: inputUsdPer1M / 2, + cacheWriteUsdPer1M: inputUsdPer1M * 1.5, + }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 340c5cd985..5b4e0bb1a7 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -3,6 +3,11 @@ import type { AttachmentRef, ShellRunUpdate } from '@maka/core/events'; import type { PlanSessionState, PlanUserControlInput } from '@maka/core/plan'; import { decodeStoredMessageForRead, type StoredMessage } from '@maka/core/session'; import type { Task } from '@maka/core/task-ledger'; +import { + canonicalPricingConfigsEqual, + comparePricingModelKeys, +} from '@maka/core/usage-stats/pricing'; +import type { PricingConfig } from '@maka/core/usage-stats/types'; import { type DirectRequestOperationKey, type RuntimeHostConnection, @@ -11,6 +16,8 @@ import { } from '@maka/runtime-host/client'; import { ARTIFACT_INGEST_CHUNK_MAX_BYTES, + decodePricingMutateInput, + type EffectivePricingEntry, type InteractionAnswerInput, type GoalControlAction, type GoalProjection, @@ -18,6 +25,8 @@ import { type OperationOutput, type PlanProjectionItem, type PlanQueryResult, + type PricingMutation, + type PricingQueryResult, type QueueRetractInput, type QueueRetractResult, type SessionCatalogFilter, @@ -41,6 +50,7 @@ import { } from '@maka/runtime-host/protocol'; const MAX_OPTIMISTIC_ATTEMPTS = 3; +const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; export type DesktopSessionConfigurationPatch = Partial; @@ -48,6 +58,8 @@ export type DesktopRuntimeHostClientErrorCode = | 'catalog_unstable' | 'client_closed' | 'projection_unstable' + | 'pricing_snapshot_stale' + | 'pricing_unstable' | 'revision_conflict' | 'session_not_found' | 'unsupported_session'; @@ -69,12 +81,112 @@ export interface DesktopRuntimeHostSession { close(): Promise; } +export interface DesktopPricingSnapshot { + readonly hostEpoch: string; + readonly connectionId: string; + readonly revision: number; + readonly entries: readonly EffectivePricingEntry[]; +} + +export interface DesktopPricingMutationInput { + readonly base: DesktopPricingSnapshot; + readonly mutation: PricingMutation; +} + +export type DesktopPricingMutationOutcome = + | { + readonly kind: 'saved'; + readonly disposition: 'committed' | 'unchanged'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'saved_refresh_failed'; + readonly disposition: 'committed' | 'unchanged'; + } + | { + readonly kind: 'synchronized' | 'review_required'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'reconciliation_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + }; + +type PricingReconciliationTarget = + | { readonly kind: 'upsert'; readonly pricing: Readonly } + | { + readonly kind: 'delete'; + readonly modelKey: string; + readonly expected: 'builtin' | 'unpriced' | 'no_override'; + }; + export class DesktopRuntimeHostClient { readonly #sessions = new Set(); #closeTask: Promise | undefined; constructor(private readonly connection: RuntimeHostConnection) {} + async loadPricingSnapshot(): Promise { + for (let attempt = 0; attempt < MAX_PRICING_SNAPSHOT_ATTEMPTS; attempt += 1) { + const snapshot = await this.#readPricingSnapshot(); + if (snapshot) return snapshot; + } + throw new DesktopRuntimeHostClientError( + 'pricing_unstable', + 'Pricing kept changing while Desktop read it', + ); + } + + async applyPricingMutation( + input: DesktopPricingMutationInput, + ): Promise { + this.#assertOpen(); + if ( + input.base.hostEpoch !== this.connection.hostEpoch || + input.base.connectionId !== this.connection.connectionId + ) { + throw new DesktopRuntimeHostClientError( + 'pricing_snapshot_stale', + 'Pricing snapshot belongs to a different Runtime Host connection', + ); + } + const request = decodePricingMutateInput({ + expectedRevision: input.base.revision, + mutation: input.mutation, + }); + const reconciliationTarget = createPricingReconciliationTarget( + input.base, + request.mutation, + ); + let result: OperationOutput<'pricing.mutate'>; + try { + result = await this.#request('pricing.mutate', request); + } catch (error) { + if ( + error instanceof RuntimeHostOperationError && + error.code !== 'commit_outcome_unknown' + ) { + throw error; + } + return this.#reconcilePricingMutation(reconciliationTarget, 'outcome_unknown'); + } + + if (result.kind === 'revision_conflict') { + return this.#reconcilePricingMutation(reconciliationTarget, 'revision_conflict'); + } + + try { + return { + kind: 'saved', + disposition: result.kind, + snapshot: await this.loadPricingSnapshot(), + }; + } catch { + return { kind: 'saved_refresh_failed', disposition: result.kind }; + } + } + async listSessions(filter?: SessionCatalogFilter): Promise { for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) { const sessions = await this.#readCatalog(filter); @@ -554,6 +666,74 @@ export class DesktopRuntimeHostClient { } } + async #readPricingSnapshot(): Promise { + this.#assertOpen(); + const first = await this.#request('pricing.query', { kind: 'start' }); + if (first.kind !== 'page' || first.offset !== 0) { + throw new DesktopRuntimeHostClientError( + 'pricing_unstable', + 'Runtime Host returned an invalid initial Pricing page', + ); + } + const entries = [...first.entries]; + const offsets = new Set([0]); + let page: Extract = first; + while (page.nextOffset !== null) { + const offset = page.nextOffset; + if (offset <= page.offset || offsets.has(offset)) { + throw new DesktopRuntimeHostClientError( + 'pricing_unstable', + 'Runtime Host repeated a Pricing page offset', + ); + } + offsets.add(offset); + const next = await this.#request('pricing.query', { + kind: 'continue', + revision: first.revision, + offset, + }); + if (next.kind === 'revision_changed') return undefined; + if (next.revision !== first.revision || next.offset !== offset) { + throw new DesktopRuntimeHostClientError( + 'pricing_unstable', + 'Runtime Host returned an inconsistent Pricing page', + ); + } + entries.push(...next.entries); + page = next; + } + if (!pricingEntriesAreCanonical(entries)) { + throw new DesktopRuntimeHostClientError( + 'pricing_unstable', + 'Runtime Host returned non-canonical Pricing pages', + ); + } + return { + hostEpoch: this.connection.hostEpoch, + connectionId: this.connection.connectionId, + revision: first.revision, + entries, + }; + } + + async #reconcilePricingMutation( + target: PricingReconciliationTarget, + reason: 'revision_conflict' | 'outcome_unknown', + ): Promise { + try { + const snapshot = await this.loadPricingSnapshot(); + return { + kind: pricingTargetMatchesSnapshot(target, snapshot) + ? 'synchronized' + : 'review_required', + reason, + snapshot, + }; + } catch { + return { kind: 'reconciliation_unavailable', reason }; + } + } + async #updateSession( sessionId: string, update: (current: SessionCatalogProjection) => Promise, @@ -756,3 +936,53 @@ function unstableProjection(name: string, sessionId: string): DesktopRuntimeHost `Runtime Host ${name} kept changing while Desktop read Session ${sessionId}`, ); } + +function createPricingReconciliationTarget( + base: DesktopPricingSnapshot, + mutation: PricingMutation, +): PricingReconciliationTarget { + if (mutation.kind === 'upsert') return { kind: 'upsert', pricing: mutation.pricing }; + const baseEntry = base.entries.find(({ pricing }) => pricing.modelKey === mutation.modelKey); + const expected = + baseEntry?.source === 'custom' + ? baseEntry.resetEffect === 'restore_builtin' + ? 'builtin' + : 'unpriced' + : 'no_override'; + return { kind: 'delete', modelKey: mutation.modelKey, expected }; +} + +function pricingTargetMatchesSnapshot( + target: PricingReconciliationTarget, + snapshot: DesktopPricingSnapshot, +): boolean { + const current = snapshot.entries.find( + ({ pricing }) => pricing.modelKey === pricingTargetModelKey(target), + ); + if (target.kind === 'upsert') { + return ( + current?.source === 'custom' && + canonicalPricingConfigsEqual(current.pricing, target.pricing) + ); + } + switch (target.expected) { + case 'builtin': + return current?.source === 'builtin'; + case 'unpriced': + return current === undefined; + case 'no_override': + return current === undefined || current.source === 'builtin'; + } +} + +function pricingTargetModelKey(target: PricingReconciliationTarget): string { + return target.kind === 'upsert' ? target.pricing.modelKey : target.modelKey; +} + +function pricingEntriesAreCanonical(entries: readonly EffectivePricingEntry[]): boolean { + return entries.every( + (entry, index) => + index === 0 || + comparePricingModelKeys(entries[index - 1]!.pricing.modelKey, entry.pricing.modelKey) < 0, + ); +}