feat(orcad): per-feature readiness latches and requires: on RPC methods (#35571)

Adds OrcadFeatureReadiness (accounts, sessionSearch, orchestration,
unshownPlacement) and a requires: field on defineMethod. The dispatcher
waits a bounded 10 s for a method's features, then refuses with the
existing retryable runtime_unavailable. terminal.adoptOrphans waits for
unshown placement; reconcile-terminals stays on the critical path. Every
latch opens where its phase finishes today, before RPC starts, so
behaviour is unchanged.
This commit is contained in:
Neil
2026-10-28 19:12:46 -07:00
committed by GitHub
parent 008a7345a8
commit 1e8d09aaf7
13 changed files with 440 additions and 9 deletions
+7 -6
View File
@@ -29,7 +29,8 @@ import { installOrcadProcessErrorGuards } from './orcad-process-error-guards'
import { orcadWsPortOptions } from './orcad-ws-port-policy'
import { createOrcadStartupTiming } from './orcad-startup-timing'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { placeUnshownLiveTerminalsBestEffort } from '../runtime/terminal/adoption/unshown-live-terminal-placement'
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
import { installOrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
export { parseArgs }
@@ -178,6 +179,8 @@ async function startOrcadRuntime(
registerCleanup
)
// Gates open where each phase finishes today; RPC starts after all of them (no behavior change).
const featureReadiness = installOrcadFeatureReadiness()
const { createOrcadAccountServices } = await import('./orcad-account-services')
const accountLaunch = createOrcadAccountServices({
store: profileStore,
@@ -224,6 +227,7 @@ async function startOrcadRuntime(
// Restore prior desktop-pushed account selection before automations or CLI launches.
const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store')
restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath)
featureReadiness.open('accounts')
const { installOrcadSessionSearchService } = await import('./orcad-session-search')
sessionSearch = await installOrcadSessionSearchService({
@@ -231,6 +235,7 @@ async function startOrcadRuntime(
getSettings: () => profileStore.getSettings()
})
getAppEnvironment().onWillQuit(() => sessionSearch?.dispose())
featureReadiness.open('sessionSearch')
const { startAgentStateRulesLiveUpdates } =
await import('../runtime/agent-state-rules/agent-state-rules-live-update')
@@ -266,11 +271,7 @@ async function startOrcadRuntime(
// Recover browser/orchestration authority before clients attach to the runtime graph.
runtime.rehydrateClientHostedBrowserPages()
await timed('orchestration', () => runtime.refreshRestoredOrchestrationAuthority())
await timed('reconcile-terminals', () => runtime.reconcileLegacyWorkerTerminals())
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await placeUnshownLiveTerminalsBestEffort(runtime)
}
await restoreOrcadTerminalAuthority(runtime, timed, featureReadiness)
runtime.agentTransitions.seedRestoredRows()
void runtime.sidebarFacts?.reconcileSortStampTiers(getLocalPtyProvider())
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { OrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
const steps = vi.hoisted(() => new Array<string>())
vi.mock('../runtime/terminal/adoption/unshown-live-terminal-placement', () => ({
placeUnshownLiveTerminalsBestEffort: async () => {
steps.push('placement')
}
}))
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
describe('restoreOrcadTerminalAuthority', () => {
it('opens each latch only once its phase is done, with reconcile ungated in between', async () => {
const readiness = new OrcadFeatureReadiness(['orchestration', 'unshownPlacement'])
const latches = (): string =>
`orchestration=${readiness.isOpen('orchestration')} placement=${readiness.isOpen('unshownPlacement')}`
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the two restore phases are called.
const runtime = {
refreshRestoredOrchestrationAuthority: async () => {
steps.push(`orchestration ${latches()}`)
},
reconcileLegacyWorkerTerminals: async () => {
steps.push(`reconcile ${latches()}`)
}
} as unknown as OrcaRuntimeService
await restoreOrcadTerminalAuthority(runtime, (_name, run) => run(), readiness)
expect(steps).toEqual([
'orchestration orchestration=false placement=false',
'reconcile orchestration=true placement=false',
'placement'
])
expect(readiness.isOpen('unshownPlacement')).toBe(true)
})
})
@@ -0,0 +1,21 @@
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { OrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
import { placeUnshownLiveTerminalsBestEffort } from '../runtime/terminal/adoption/unshown-live-terminal-placement'
import type { createOrcadStartupTiming } from './orcad-startup-timing'
/** Restored orchestration authority, the legacy terminal reconcile and unshown placement, in order. */
export async function restoreOrcadTerminalAuthority(
runtime: OrcaRuntimeService,
timed: ReturnType<typeof createOrcadStartupTiming>['time'],
readiness: OrcadFeatureReadiness
): Promise<void> {
await timed('orchestration', () => runtime.refreshRestoredOrchestrationAuthority())
readiness.open('orchestration')
// Terminal inventory stays on the critical path: no latch, every client needs it at connect.
await timed('reconcile-terminals', () => runtime.reconcileLegacyWorkerTerminals())
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await placeUnshownLiveTerminalsBestEffort(runtime)
}
readiness.open('unshownPlacement')
}
+6
View File
@@ -23,6 +23,7 @@ import type { RpcCallerIdentity } from './rpc-caller-identity'
import type { OrcadPeerDesktopsResult } from '../../../shared/orcad-peer-desktops-contract'
import type { RpcBinarySender } from './rpc-binary-sender'
import type { RpcMethodPermission } from './rpc-method-permission'
import type { OrcadFeature } from './orcad-feature-readiness'
import type {
RegisterDeviceKeyParams,
RegisterDeviceKeyResult
@@ -178,6 +179,8 @@ export type RpcTypedMethod<TName extends string, TSchema extends ZodType | null,
// Every new method must explicitly choose caller permissions.
readonly permission: RpcMethodPermission
readonly params: TSchema
// Startup phases this method needs; the dispatcher waits for them, bounded (orcad only).
readonly requires?: readonly OrcadFeature[]
readonly handler: (params: RpcParsedParams<TSchema>, ctx: RpcContext) => TResult
}
@@ -192,6 +195,7 @@ export type RpcTypedStreamingMethod<TName extends string, TSchema extends ZodTyp
readonly name: TName
readonly permission: RpcMethodPermission
readonly params: TSchema
readonly requires?: readonly OrcadFeature[]
readonly stream: true
readonly handler: (
params: RpcParsedParams<TSchema>,
@@ -210,6 +214,7 @@ type RpcMethodWithParams<P> = {
readonly name: string
readonly permission: RpcMethodPermission
readonly params: ZodType | null
readonly requires?: readonly OrcadFeature[]
readonly handler: (params: P, ctx: RpcContext) => unknown
}
@@ -217,6 +222,7 @@ type RpcStreamingMethodWithParams<P> = {
readonly name: string
readonly permission: RpcMethodPermission
readonly params: ZodType | null
readonly requires?: readonly OrcadFeature[]
readonly stream: true
readonly handler: (params: P, ctx: RpcContext, emit: (result: unknown) => void) => Promise<void>
}
@@ -0,0 +1,107 @@
import './unused-default-rpc-methods.test-fixture'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, defineStreamingMethod, type RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import {
_resetOrcadFeatureReadinessForTests,
installOrcadFeatureReadiness,
ORCAD_FEATURE_WAIT_MS
} from './orcad-feature-readiness'
const request = (method: string): RpcRequest => ({ id: 'r1', authToken: 't', method, params: {} })
function dispatcher(): { dispatcher: RpcDispatcher; ran: string[] } {
const ran: string[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members for plain methods.
const runtime = {
getRuntimeId: () => 'rt',
recordFeatureInteraction: vi.fn()
} as unknown as OrcaRuntimeService
const methods = [
defineMethod({
name: 'aiVault.searchStatus',
requires: ['sessionSearch'],
permission: 'workspace',
params: z.object({}),
handler: () => {
ran.push('searchStatus')
return { ready: true }
}
}),
defineMethod({
name: 'status.get',
permission: 'workspace',
params: z.object({}),
handler: () => {
ran.push('status')
return {}
}
}),
defineStreamingMethod({
name: 'accounts.subscribe',
requires: ['accounts'],
permission: 'workspace',
params: z.object({}),
handler: async (_params, _ctx, emit) => {
ran.push('subscribe')
emit({ type: 'ready' })
}
})
]
return { dispatcher: new RpcDispatcher({ runtime, methods }), ran }
}
afterEach(() => {
_resetOrcadFeatureReadinessForTests()
vi.useRealTimers()
})
describe('RPC methods that require an orcad startup phase', () => {
it('run at once where no readiness is installed (the desktop)', async () => {
const { dispatcher: rpc, ran } = dispatcher()
await expect(rpc.dispatch(request('aiVault.searchStatus'))).resolves.toMatchObject({ ok: true })
expect(ran).toEqual(['searchStatus'])
})
it('wait for their phase, while methods that need none run at once', async () => {
const readiness = installOrcadFeatureReadiness()
const { dispatcher: rpc, ran } = dispatcher()
const search = rpc.dispatch(request('aiVault.searchStatus'))
await expect(rpc.dispatch(request('status.get'))).resolves.toMatchObject({ ok: true })
expect(ran).toEqual(['status'])
readiness.open('sessionSearch')
await expect(search).resolves.toMatchObject({ ok: true, result: { ready: true } })
expect(ran).toEqual(['status', 'searchStatus'])
})
it('refuse retryably with runtime_unavailable once the bounded wait runs out', async () => {
vi.useFakeTimers()
installOrcadFeatureReadiness()
const { dispatcher: rpc, ran } = dispatcher()
const search = rpc.dispatch(request('aiVault.searchStatus'))
await vi.advanceTimersByTimeAsync(ORCAD_FEATURE_WAIT_MS)
await expect(search).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_unavailable' }
})
expect(ran).toEqual([])
})
it('gate streaming methods the same way', async () => {
const readiness = installOrcadFeatureReadiness()
const { dispatcher: rpc, ran } = dispatcher()
const replies: string[] = []
const stream = rpc.dispatchStreaming(request('accounts.subscribe'), (reply) =>
replies.push(reply)
)
await Promise.resolve()
expect(ran).toEqual([])
readiness.open('accounts')
await stream
expect(ran).toEqual(['subscribe'])
expect(replies).toHaveLength(1)
})
})
+14
View File
@@ -33,6 +33,7 @@ import { recordRuntimeFeatureInteraction } from './runtime-feature-interaction'
import type { RpcCallerScope } from './rpc-caller-scope'
import { runWithActiveRpcCaller } from './rpc-active-caller'
import { admitRpcRequest } from './rpc-request-admission-prologue'
import { rpcMethodFeatureWait } from './orcad-feature-readiness'
export type DispatcherOptions = {
runtime: OrcaRuntimeService
@@ -83,6 +84,10 @@ export class RpcDispatcher {
emulatorProbe(`rpc ${request.method}`, request.params)
}
try {
const featureWait = rpcMethodFeatureWait(admission.method.requires)
if (featureWait) {
await featureWait
}
return successResponse(request.id, meta, await this.invokeUnary(admission, options))
} catch (error) {
if (probeEmulator) {
@@ -113,6 +118,15 @@ export class RpcDispatcher {
return
}
const { method, request, params, caller } = admission
const featureWait = rpcMethodFeatureWait(method.requires)
if (featureWait) {
try {
await featureWait
} catch (error) {
reply(JSON.stringify(mapDispatcherError(request, meta, error)))
return
}
}
if (!isStreamingMethod(method)) {
try {
+14 -1
View File
@@ -1,4 +1,5 @@
import { defineMethod, defineStreamingMethod, type RpcContext } from '../core'
import type { OrcadFeature } from '../orcad-feature-readiness'
import { rpcCallerOperationKey } from '../rpc-caller-identity'
import { HostAccountSignInFlows } from '../../host-account-sign-in-flows'
import type { OrcaRuntimeService } from '../../orca-runtime'
@@ -46,7 +47,7 @@ function signInOwner({ caller }: Pick<RpcContext, 'caller'>): string {
return rpcCallerOperationKey(caller)
}
export const ACCOUNT_METHODS = [
const ACCOUNT_METHOD_DEFINITIONS = [
// Only the owning desktop pushes this host's account launch environment, including WSL.
defineMethod({
name: 'accounts.setHostLaunchEnv',
@@ -270,3 +271,15 @@ export const ACCOUNT_METHODS = [
}
})
]
// The desktop's launch-env push and unsubscribes never touch the account services.
const ACCOUNT_SERVICE_FREE_METHODS: ReadonlySet<string> = new Set([
'accounts.setHostLaunchEnv',
'accounts.unsubscribe'
])
export const ACCOUNT_METHODS = ACCOUNT_METHOD_DEFINITIONS.map((method) =>
ACCOUNT_SERVICE_FREE_METHODS.has(method.name)
? method
: { ...method, requires: ['accounts'] satisfies OrcadFeature[] }
)
+3
View File
@@ -45,6 +45,7 @@ export {
export const AI_VAULT_METHODS = [
defineMethod({
name: 'aiVault.searchSessions',
requires: ['sessionSearch'],
permission: 'workspace',
params: AiVaultSearchRequestSchema,
handler: (params, context) => {
@@ -60,6 +61,7 @@ export const AI_VAULT_METHODS = [
}),
defineMethod({
name: 'aiVault.searchStatus',
requires: ['sessionSearch'],
permission: 'workspace',
params: AiVaultSearchStatusRequestSchema,
handler: (params, { clientKind }) =>
@@ -67,6 +69,7 @@ export const AI_VAULT_METHODS = [
}),
defineMethod({
name: 'aiVault.setSearchEnabled',
requires: ['sessionSearch'],
permission: 'settings-write',
params: AiVaultSetSearchEnabledParamsSchema,
handler: async (params, { runtime, clientKind, pairedDeviceId }) => {
@@ -1,3 +1,4 @@
import { requiringOrcadFeature } from '../orcad-feature-readiness'
import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs'
import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods'
import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods'
@@ -11,7 +12,8 @@ import { ORCHESTRATION_GATE_METHODS } from './orchestration/gates/gates'
import { ORCHESTRATION_RESET_METHODS } from './orchestration/runs/reset-methods'
import { ORCHESTRATION_CALLER_METHODS } from './orchestration/caller-show'
export const ORCHESTRATION_METHODS = [
// Every orchestration method reads the authority restored at startup.
export const ORCHESTRATION_METHODS = requiringOrcadFeature('orchestration', [
...ORCHESTRATION_RUN_METHODS,
...ORCHESTRATION_WORKER_METHODS,
...ORCHESTRATION_FEDERATION_METHODS,
@@ -24,4 +26,4 @@ export const ORCHESTRATION_METHODS = [
...ORCHESTRATION_GATE_METHODS,
...ORCHESTRATION_RESET_METHODS,
...ORCHESTRATION_CALLER_METHODS
]
])
@@ -4,6 +4,8 @@ import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-o
export const TERMINAL_ORPHAN_METHODS = [
defineMethod({
name: 'terminal.adoptOrphans',
// Adopting before placement could adopt a PTY the host is about to place itself.
requires: ['unshownPlacement'],
permission: 'workspace',
params: TerminalAdoptOrphans,
handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params)
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { ALL_RPC_METHODS } from './methods'
import { ACCOUNT_METHODS } from './methods/accounts'
import type { OrcadFeature } from './orcad-feature-readiness'
const requiresOf = (name: string): readonly OrcadFeature[] | undefined =>
ALL_RPC_METHODS.find((method) => method.name === name)?.requires
describe('which RPC methods wait for an orcad startup phase', () => {
it('gates the account services, search index, orchestration and orphan adoption', () => {
for (const method of ALL_RPC_METHODS) {
const expected: OrcadFeature[] = []
// Claude/Codex account services only; usage credentials and Antigravity live elsewhere.
if (
ACCOUNT_METHODS.some((account) => account.name === method.name) &&
method.name !== 'accounts.setHostLaunchEnv' &&
method.name !== 'accounts.unsubscribe'
) {
expected.push('accounts')
}
if (method.name.startsWith('orchestration.')) {
expected.push('orchestration')
}
if (/^aiVault\.(searchSessions|searchStatus|setSearchEnabled)$/.test(method.name)) {
expected.push('sessionSearch')
}
if (method.name === 'terminal.adoptOrphans') {
expected.push('unshownPlacement')
}
expect({ name: method.name, requires: method.requires ?? [] }).toEqual({
name: method.name,
requires: expected
})
}
})
it('never gates what every client calls at connect', () => {
for (const name of [
'status.get',
'repo.list',
'session.tabs.subscribe',
'terminal.list',
'worktree.teardownMissingTerminals',
'accounts.setHostLaunchEnv'
]) {
expect(ALL_RPC_METHODS.some((method) => method.name === name)).toBe(true)
expect(requiresOf(name)).toBeUndefined()
}
})
// No behavior change yet: orcad opens every latch before RPC starts accepting calls.
it('opens every latch before orcad starts RPC', () => {
const read = (file: string): string =>
readFileSync(join(__dirname, '../../orcad', file), 'utf8')
const entry = read('orcad-entry.ts')
const restore = read('orcad-restored-terminal-authority.ts')
const rpcStart = entry.indexOf("await timed('rpc', () => rpc.start())")
const restored = entry.indexOf('await restoreOrcadTerminalAuthority(')
expect(rpcStart).toBeGreaterThan(0)
expect(restored).toBeGreaterThan(0)
expect(restored).toBeLessThan(rpcStart)
for (const feature of ['accounts', 'sessionSearch']) {
const opened = entry.indexOf(`featureReadiness.open('${feature}')`)
expect(opened, feature).toBeGreaterThan(0)
expect(opened, feature).toBeLessThan(rpcStart)
}
for (const feature of ['orchestration', 'unshownPlacement']) {
expect(restore).toContain(`readiness.open('${feature}')`)
}
})
})
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
_resetOrcadFeatureReadinessForTests,
installOrcadFeatureReadiness,
ORCAD_FEATURES,
rpcMethodFeatureWait
} from './orcad-feature-readiness'
afterEach(() => {
_resetOrcadFeatureReadinessForTests()
vi.useRealTimers()
})
describe('OrcadFeatureReadiness', () => {
it('adds no await for open or undeclared features, so ungated dispatch starts synchronously', () => {
expect(rpcMethodFeatureWait(undefined)).toBeNull()
expect(rpcMethodFeatureWait(['accounts'])).toBeNull()
const readiness = installOrcadFeatureReadiness()
expect(rpcMethodFeatureWait([])).toBeNull()
readiness.open('accounts')
expect(rpcMethodFeatureWait(['accounts'])).toBeNull()
})
it('starts with every feature closed under orcad and waits for all a method names', async () => {
const readiness = installOrcadFeatureReadiness()
expect(ORCAD_FEATURES.every((feature) => !readiness.isOpen(feature))).toBe(true)
let settled = false
const wait = rpcMethodFeatureWait(['accounts', 'orchestration'])?.then(() => {
settled = true
})
readiness.open('accounts')
await Promise.resolve()
expect(settled).toBe(false)
readiness.open('orchestration')
await wait
expect(settled).toBe(true)
})
it('refuses with runtime_unavailable after the bound and leaves no timer behind', async () => {
vi.useFakeTimers()
installOrcadFeatureReadiness()
const wait = rpcMethodFeatureWait(['sessionSearch'], 50)
const refused = expect(wait).rejects.toThrow('runtime_unavailable')
await vi.advanceTimersByTimeAsync(50)
await refused
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,102 @@
/**
* Startup phases an orcad host finishes after its first RPC could arrive. A method that needs one
* declares it (`requires`); the dispatcher waits a bounded time, then refuses retryably. Hosts that
* never install a readiness (the desktop) have every feature open.
*/
export type OrcadFeature = 'accounts' | 'sessionSearch' | 'orchestration' | 'unshownPlacement'
export const ORCAD_FEATURES: readonly OrcadFeature[] = [
'accounts',
'sessionSearch',
'orchestration',
'unshownPlacement'
]
// Long enough to ride out a cold start; short enough that a caller's own retry takes over.
export const ORCAD_FEATURE_WAIT_MS = 10_000
export class OrcadFeatureReadiness {
private readonly pending = new Map<OrcadFeature, { opened: Promise<void>; open: () => void }>()
constructor(closed: readonly OrcadFeature[] = []) {
for (const feature of closed) {
let open!: () => void
const opened = new Promise<void>((resolve) => {
open = resolve
})
this.pending.set(feature, { opened, open })
}
}
isOpen(feature: OrcadFeature): boolean {
return !this.pending.has(feature)
}
open(feature: OrcadFeature): void {
this.pending.get(feature)?.open()
this.pending.delete(feature)
}
/** True once every feature is open; false if any is still closed after `timeoutMs`. */
async waitFor(features: readonly OrcadFeature[], timeoutMs: number): Promise<boolean> {
const waits = features.flatMap((feature) => this.pending.get(feature)?.opened ?? [])
if (waits.length === 0) {
return true
}
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs)
})
try {
return await Promise.race([Promise.all(waits).then(() => true), timedOut])
} finally {
clearTimeout(timer)
}
}
}
// One runtime per orcad process; the desktop's never installs one, so all features read open.
let current = new OrcadFeatureReadiness()
export function orcadFeatureReadiness(): OrcadFeatureReadiness {
return current
}
/** orcad startup: every feature starts closed and opens where its phase finishes. */
export function installOrcadFeatureReadiness(): OrcadFeatureReadiness {
current = new OrcadFeatureReadiness(ORCAD_FEATURES)
return current
}
export function _resetOrcadFeatureReadinessForTests(): void {
current = new OrcadFeatureReadiness()
}
/**
* The wait for a method's required features, or null when nothing is closed, so ungated dispatch
* keeps its synchronous start. Refuses with the existing retryable `runtime_unavailable`.
*/
export function rpcMethodFeatureWait(
requires: readonly OrcadFeature[] | undefined,
timeoutMs = ORCAD_FEATURE_WAIT_MS
): Promise<void> | null {
if (!requires?.some((feature) => !current.isOpen(feature))) {
return null
}
return current.waitFor(requires, timeoutMs).then((ready) => {
if (!ready) {
throw new Error('runtime_unavailable')
}
})
}
/** Declares `feature` on each method of a family, e.g. every `orchestration.*` method. */
export function requiringOrcadFeature<M extends { readonly requires?: readonly OrcadFeature[] }>(
feature: OrcadFeature,
methods: readonly M[]
): M[] {
return methods.map((method) => ({
...method,
requires: [...(method.requires ?? []), feature]
}))
}