mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
refactor(mobile): simplify RPC descriptors and fence the contract module
This commit is contained in:
@@ -13,8 +13,8 @@ import { describe, expect, it } from 'vitest'
|
||||
* drift the contract removed — and it buys it silently, since the code still compiles and the
|
||||
* types still read as validated.
|
||||
*
|
||||
* The fenced region is computed, not listed: a non-test file is in it if it imports the
|
||||
* operation API, the operation contract or the result-reader factory, or if it re-exports a
|
||||
* The fenced region includes the operation API, contract and result-reader factory, plus
|
||||
* non-test files importing them and files that re-export a
|
||||
* file that is (transitively). Step 4's operation modules therefore land inside the fence the
|
||||
* moment they are written, with nothing to remember.
|
||||
*
|
||||
@@ -162,7 +162,7 @@ function moduleKey(path: string): string {
|
||||
return path.replace(/\.[jt]sx?$/, '')
|
||||
}
|
||||
|
||||
const region = new Set<string>()
|
||||
const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path))))
|
||||
for (const [path, { imports }] of edges) {
|
||||
if (imports.some((target) => REGION_SEEDS.has(target))) {
|
||||
region.add(path)
|
||||
@@ -209,6 +209,7 @@ describe('RPC operation cast fence', () => {
|
||||
it('puts every operation module in the fenced region', () => {
|
||||
for (const file of [
|
||||
'src/transport/rpc-operation.ts',
|
||||
'src/transport/rpc-operation-contract.ts',
|
||||
'src/transport/rpc-operation-test-families.ts',
|
||||
'src/transport/rpc-operation-compile-fence.ts',
|
||||
'src/transport/rpc-operation-result-reader.ts',
|
||||
|
||||
@@ -22,27 +22,22 @@ import type {
|
||||
// acceptance policy, the interpretation barrier, or the send-side params for itself. Every
|
||||
// expect-error directive below is that claim as an assertion — tsc fails on a directive that
|
||||
// stops catching an error, so `pnpm --dir mobile typecheck` is the gate. Nothing here runs and
|
||||
// no app code imports it. The FENCE markers are pinned by rpc-operation.test.ts.
|
||||
// no app code imports it.
|
||||
|
||||
declare const client: RpcClient
|
||||
|
||||
// FENCE: variant-readers-cannot-be-empty
|
||||
// @ts-expect-error a variant reader combinator must have at least one reader
|
||||
const _fenceEmptyVariantReaders = rpcResultVariants([])
|
||||
|
||||
// FENCE: probe-cannot-take-a-reader
|
||||
export const fenceProbeWithReader: CapabilityProbeRpcDefinition<'worktree.ps', 'on-settle'> = {
|
||||
name: 'fence.probeWithReader',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: [],
|
||||
// @ts-expect-error a refusal-code probe reads no payload, so it cannot carry a reader
|
||||
read: workspaceRowsReader
|
||||
}
|
||||
|
||||
// FENCE: decoding-policy-needs-a-reader
|
||||
// @ts-expect-error 'require-result-or-throw' has no value to return without a reader
|
||||
export const fenceDecodingWithoutReader: RequireResultRpcDefinition<
|
||||
'worktree.ps',
|
||||
@@ -53,12 +48,9 @@ export const fenceDecodingWithoutReader: RequireResultRpcDefinition<
|
||||
name: 'fence.decodingWithoutReader',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
|
||||
// FENCE: acceptance-must-be-a-named-policy
|
||||
// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary
|
||||
export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine'
|
||||
|
||||
@@ -71,7 +63,6 @@ const fenceTextReader: RpcCompatibleReader<string, 'text', string> = (raw) => ({
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
})
|
||||
|
||||
// FENCE: object-policy-reader-sees-an-object
|
||||
export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition<
|
||||
'worktree.ps',
|
||||
'text',
|
||||
@@ -82,26 +73,20 @@ export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition<
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: [],
|
||||
// @ts-expect-error the policy admits a non-null object, not the string this reader expects
|
||||
read: fenceTextReader
|
||||
}
|
||||
|
||||
// FENCE: define-rejects-a-mismatched-definition
|
||||
export const fenceDefineRejectsMismatch = defineRpcOperation({
|
||||
name: 'fence.defineRejectsMismatch',
|
||||
method: 'worktree.ps',
|
||||
// @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: [],
|
||||
// @ts-expect-error ... and the reader it would need is exactly what the probe overload bans
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
// FENCE: method-must-exist-in-the-catalog
|
||||
// @ts-expect-error only generated catalog method names are addressable
|
||||
export const fenceUnknownMethod: RpcMethodName = 'worktree.nope'
|
||||
|
||||
@@ -109,22 +94,18 @@ export const fenceUnknownMethod: RpcMethodName = 'worktree.nope'
|
||||
// coercing builders admit) are both wrong for a sender in opposite directions, so these pin
|
||||
// the two failures a regression to either one would reintroduce.
|
||||
|
||||
// FENCE: send-params-omit-a-defaulted-field
|
||||
// `query` and `limit` carry .default(), so a sender may leave them out. Under z.output both
|
||||
// read as required and this line stops compiling.
|
||||
export const fenceOmitsDefaultedField: RpcSendParams<'files.searchPaths'> = { worktree: 'w' }
|
||||
|
||||
// FENCE: send-params-reject-a-wrong-typed-field
|
||||
export const fenceRejectsWrongFieldType: RpcSendParams<'files.searchPaths'> = {
|
||||
// @ts-expect-error z.input of a z.unknown().transform builder admits any value; this does not
|
||||
worktree: 42
|
||||
}
|
||||
|
||||
// FENCE: send-params-still-name-required-fields
|
||||
// @ts-expect-error `worktree` has neither a default nor an optional marker
|
||||
export const fenceKeepsRequiredField: RpcSendParams<'files.searchPaths'> = { query: 'x' }
|
||||
|
||||
// FENCE: send-params-never-tighten-the-parsed-shape
|
||||
// Catalog-wide: anything a handler could have been handed is something a sender may write.
|
||||
// A method that ever resolves tighter than its parsed shape lands in this union.
|
||||
declare const fenceTighterThanParsed: {
|
||||
@@ -132,7 +113,6 @@ declare const fenceTighterThanParsed: {
|
||||
}[RpcMethodName] & {}
|
||||
export const fenceNoTighterMethod: never = fenceTighterThanParsed
|
||||
|
||||
// FENCE: send-params-do-not-degenerate-to-unknown
|
||||
// z.input collapses every coercing builder to `unknown`. Only plugins.panelAction may be
|
||||
// unknown, because its schema is literally z.unknown().
|
||||
declare const fenceUnknownParams: {
|
||||
@@ -141,21 +121,18 @@ declare const fenceUnknownParams: {
|
||||
export const fenceOnlyDeclaredUnknown: 'plugins.panelAction' = fenceUnknownParams
|
||||
|
||||
export async function fenceBarrierAndParams(): Promise<void> {
|
||||
// FENCE: declared-barrier-cannot-be-moved-earlier
|
||||
await runRpcOperation(
|
||||
client,
|
||||
// @ts-expect-error this family interprets after all requests, so it has no on-settle run
|
||||
workspaceListAtBarrier,
|
||||
{}
|
||||
)
|
||||
// FENCE: on-settle-operation-cannot-defer-to-a-barrier
|
||||
startRpcOperation(
|
||||
client,
|
||||
// @ts-expect-error an on-settle family must not be parked behind someone else's barrier
|
||||
worktreePsProbe,
|
||||
{}
|
||||
)
|
||||
// FENCE: params-are-fixed-by-the-method
|
||||
await runRpcOperation(
|
||||
client,
|
||||
workspaceListOrNull,
|
||||
@@ -165,13 +142,11 @@ export async function fenceBarrierAndParams(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function fenceVerdictTypes(): Promise<void> {
|
||||
// FENCE: probe-verdict-is-not-a-decoded-value
|
||||
// @ts-expect-error the probe's policy yields a boolean, not the other family's rows
|
||||
const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {})
|
||||
void rows
|
||||
}
|
||||
|
||||
// FENCE: manual-decoding-descriptor-needs-a-reader
|
||||
// @ts-expect-error the public descriptor also requires decoding, even without the factory
|
||||
export const fenceManualWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
@@ -183,12 +158,9 @@ export const fenceManualWithoutReader: RpcOperation<
|
||||
name: 'fence.manual',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
|
||||
// FENCE: broad-policy-still-requires-a-reader
|
||||
// @ts-expect-error widening the policy cannot disconnect it from its required reader
|
||||
export const fenceBroadWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
@@ -201,12 +173,9 @@ export const fenceBroadWithoutReader: RpcOperation<
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: [],
|
||||
read: undefined
|
||||
}
|
||||
|
||||
// FENCE: object-descriptor-needs-a-reader
|
||||
// @ts-expect-error object acceptance must decode, just like require-result acceptance
|
||||
export const fenceObjectWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
@@ -218,7 +187,5 @@ export const fenceObjectWithoutReader: RpcOperation<
|
||||
name: 'fence.object',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@ export type RpcOperation<
|
||||
readonly name: string
|
||||
readonly method: Method
|
||||
readonly barrier: Barrier
|
||||
/** Reply fields this family reads — the host contract a wire change has to respect. */
|
||||
readonly consumes: readonly string[]
|
||||
/** Refresh/poll schedules this family owns; empty when it only runs on user intent. */
|
||||
readonly schedules: readonly string[]
|
||||
} & {
|
||||
[Policy in RpcAcceptanceName]: {
|
||||
readonly acceptance: Policy
|
||||
@@ -89,7 +85,7 @@ export type RpcOperation<
|
||||
// Internal interpreter view; public send APIs retain the policy/reader correlation.
|
||||
export type AnyRpcOperation = Pick<
|
||||
RpcOperation<RpcMethodName, RpcAcceptanceName, string, unknown, RpcInterpretationBarrier>,
|
||||
'name' | 'method' | 'acceptance' | 'barrier' | 'consumes' | 'schedules'
|
||||
'name' | 'method' | 'acceptance' | 'barrier'
|
||||
> & { readonly read: RpcCompatibleReader<unknown, string, unknown> | undefined }
|
||||
|
||||
/** The verdict the declared policy yields. Not a per-call choice. */
|
||||
@@ -117,8 +113,6 @@ type RpcOperationDefinition<
|
||||
name: string
|
||||
method: Method
|
||||
barrier: Barrier
|
||||
consumes: readonly string[]
|
||||
schedules: readonly string[]
|
||||
}
|
||||
|
||||
export type RequireResultRpcDefinition<
|
||||
|
||||
@@ -120,8 +120,6 @@ describe('salvage through a descriptor', () => {
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
consumes: ['worktrees.id'],
|
||||
schedules: [],
|
||||
read: salvagingReader
|
||||
})
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ export const workspaceListOrThrow = defineRpcOperation({
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
consumes: ['worktrees.id'],
|
||||
schedules: [],
|
||||
read: workspaceRowsOrLegacyReader
|
||||
})
|
||||
|
||||
@@ -46,8 +44,6 @@ export const workspaceListOrNull = defineRpcOperation({
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle',
|
||||
consumes: ['worktrees.id'],
|
||||
schedules: [],
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
@@ -55,18 +51,14 @@ export const worktreePsProbe = defineRpcOperation({
|
||||
name: 'test.worktreePsProbe',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'on-settle'
|
||||
})
|
||||
|
||||
export const terminalStreamOpener = defineRpcOperation({
|
||||
name: 'test.terminalStreamOpener',
|
||||
method: 'terminal.subscribe',
|
||||
acceptance: 'streaming-opener',
|
||||
barrier: 'on-settle',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'on-settle'
|
||||
})
|
||||
|
||||
export const workspaceListAtBarrier = defineRpcOperation({
|
||||
@@ -74,8 +66,6 @@ export const workspaceListAtBarrier = defineRpcOperation({
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'after-all-requests',
|
||||
consumes: ['worktrees.id'],
|
||||
schedules: ['test-poll'],
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
@@ -84,8 +74,6 @@ export const terminalListAtBarrier = defineRpcOperation({
|
||||
method: 'terminal.list',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'after-all-requests',
|
||||
consumes: ['terminals'],
|
||||
schedules: [],
|
||||
read: rpcResultVariant('terminals', z.object({ terminals: z.array(z.unknown()) }))
|
||||
})
|
||||
|
||||
@@ -93,9 +81,7 @@ export const worktreePsProbeAtBarrier = defineRpcOperation({
|
||||
name: 'test.worktreePsProbeAtBarrier',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'after-all-requests',
|
||||
consumes: [],
|
||||
schedules: []
|
||||
barrier: 'after-all-requests'
|
||||
})
|
||||
|
||||
export function rpcSuccess(result: unknown, streaming?: true): RpcResponse {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from './types'
|
||||
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
|
||||
@@ -334,49 +332,4 @@ describe('a descriptor', () => {
|
||||
;(workspaceListOrThrow as { barrier: string }).barrier = 'after-all-requests'
|
||||
}).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('publishes the fields it consumes and the schedules it owns', () => {
|
||||
expect(workspaceListOrThrow.consumes).toEqual(['worktrees.id'])
|
||||
expect(workspaceListOrThrow.schedules).toEqual([])
|
||||
expect(Object.isFrozen(workspaceListOrThrow.consumes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// The fence itself is checked by `tsc --noEmit` (an expect-error directive that stops catching
|
||||
// an error fails the typecheck). This pins its coverage so the file cannot be quietly gutted.
|
||||
describe('the compile fence', () => {
|
||||
const fenceSource = readFileSync(
|
||||
fileURLToPath(new URL('./rpc-operation-compile-fence.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const expectErrorDirective = `@ts-${'expect-error'}`
|
||||
|
||||
it('still asserts every rejection it is meant to', () => {
|
||||
const markers = [...fenceSource.matchAll(/\/\/ FENCE: (?<name>[a-z-]+)/g)].map(
|
||||
(match) => match.groups?.name
|
||||
)
|
||||
|
||||
expect(markers).toEqual([
|
||||
'variant-readers-cannot-be-empty',
|
||||
'probe-cannot-take-a-reader',
|
||||
'decoding-policy-needs-a-reader',
|
||||
'acceptance-must-be-a-named-policy',
|
||||
'object-policy-reader-sees-an-object',
|
||||
'define-rejects-a-mismatched-definition',
|
||||
'method-must-exist-in-the-catalog',
|
||||
'send-params-omit-a-defaulted-field',
|
||||
'send-params-reject-a-wrong-typed-field',
|
||||
'send-params-still-name-required-fields',
|
||||
'send-params-never-tighten-the-parsed-shape',
|
||||
'send-params-do-not-degenerate-to-unknown',
|
||||
'declared-barrier-cannot-be-moved-earlier',
|
||||
'on-settle-operation-cannot-defer-to-a-barrier',
|
||||
'params-are-fixed-by-the-method',
|
||||
'probe-verdict-is-not-a-decoded-value',
|
||||
'manual-decoding-descriptor-needs-a-reader',
|
||||
'broad-policy-still-requires-a-reader',
|
||||
'object-descriptor-needs-a-reader'
|
||||
])
|
||||
expect(fenceSource.split(expectErrorDirective).length - 1).toBe(17)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,8 +68,6 @@ export function defineRpcOperation(definition: RpcOperationDefinitionInput): Any
|
||||
method: definition.method,
|
||||
acceptance: definition.acceptance,
|
||||
barrier: definition.barrier,
|
||||
consumes: Object.freeze([...definition.consumes]),
|
||||
schedules: Object.freeze([...definition.schedules]),
|
||||
// Why: classifyReply only ever hands a reader the payload its own policy admitted, so
|
||||
// the object policy's narrower parameter is sound to store as unknown.
|
||||
read: definition.read as RpcCompatibleReader<unknown, string, unknown> | undefined
|
||||
|
||||
Reference in New Issue
Block a user