Merge mr-p1-registry: register creationRetiredNames and type bridge operation names

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-01 16:41:26 -04:00
11 changed files with 154 additions and 14 deletions
@@ -23,6 +23,7 @@ const CREATE_LIMITS = {
export const MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS = [
{ capability: 'workspace', operation: 'creationRepositories', limits: READ_LIMITS },
{ capability: 'workspace', operation: 'creationRetiredNames', limits: READ_LIMITS },
{ capability: 'workspace', operation: 'creationSettings', limits: READ_LIMITS },
{ capability: 'workspace', operation: 'creationTrustedHooks', limits: READ_LIMITS },
{ capability: 'workspace', operation: 'creationGitLabAvailability', limits: READ_LIMITS },
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY } from '../tasks/worktree-create-capability'
import { webHostWorkspaceCreationOperations } from '../worktree/web-host-workspace-creation-operations'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
import { MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS } from './mobile-web-production-workspace-creation-grants'
@@ -90,4 +91,45 @@ describe('mobile web workspace creation round trip', () => {
expect(sendRequest).not.toHaveBeenCalledWith('settings.get')
expect(JSON.stringify(shellMessages)).not.toMatch(/repo-secret|worktree-secret|ssh-private-id/)
})
it('reaches the retired-name adapter through the hosted creation operations', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [{ id: '/host/repo-secret', displayName: 'Orca', path: '/Users/private/orca' }]
}
}
}
if (method === 'worktree.listRetiredNames') {
return {
ok: true,
result: {
retiredNamesByRepo: { '/host/repo-secret': ['nautilus'] },
retiredNameTiersByRepo: { '/host/repo-secret': 2 }
}
}
}
throw new Error(`Unexpected method ${method}`)
})
let requestIndex = 0
const { client: pageClient, shellMessages } = createMobileWebBridgeRoundtripFixture({
grants: [...MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS],
rpcClient: { sendRequest } as unknown as RpcClient,
createRequestId: () => String.fromCharCode(65 + requestIndex++).repeat(22)
})
const operations = webHostWorkspaceCreationOperations(pageClient)
const [repository] = await operations.listRepositories()
await expect(operations.readRetiredWorktreeNames(repository!.id)).resolves.toEqual({
exhaustedTiers: 2,
names: ['nautilus']
})
expect(sendRequest).toHaveBeenLastCalledWith('worktree.listRetiredNames', {
repo: 'id:/host/repo-secret'
})
expect(JSON.stringify(shellMessages)).not.toContain('repo-secret')
})
})
@@ -2,6 +2,7 @@ import type { z } from 'zod'
import {
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS,
type MobileWebBridgeCapability,
type MobileWebBridgeOperationName,
type MobileWebBridgePageMessage,
type MobileWebBridgeShellMessage
} from '../../shared/mobile-web/bridge-contract'
@@ -23,7 +24,7 @@ export class MobileWebOneShotRequestClient {
private readonly options: {
getGrant: (
capability: MobileWebBridgeCapability,
operation: string
operation: MobileWebBridgeOperationName
) => OperationGrant | undefined
postMessage: (message: MobileWebBridgePageMessage) => boolean
envelope: () => Pick<MobileWebBridgePageMessage, 'version' | 'shellSessionId' | 'buildId'>
@@ -33,9 +34,9 @@ export class MobileWebOneShotRequestClient {
}
) {}
request<TPayload, TResult>(
capability: MobileWebBridgeCapability,
operation: string,
request<TCapability extends MobileWebBridgeCapability, TPayload, TResult>(
capability: TCapability,
operation: MobileWebBridgeOperationName<TCapability>,
payload: TPayload,
payloadSchema: z.ZodType<TPayload>,
resultSchema: z.ZodType<TResult>,
@@ -1,4 +1,5 @@
import type { ZodType } from 'zod'
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebTaskItemChecksPayloadSchema,
MobileWebTaskItemChecksResultSchema,
@@ -148,7 +149,11 @@ export class MobileWebTaskItemRequestClient extends MobileWebTaskProjectRequestC
)
}
private mutateReview(operation: string, payload: unknown, schema: ZodType) {
private mutateReview(
operation: MobileWebBridgeOperationName<'task'>,
payload: unknown,
schema: ZodType
) {
return this.requests.request(
'task',
operation,
@@ -158,7 +163,11 @@ export class MobileWebTaskItemRequestClient extends MobileWebTaskProjectRequestC
)
}
private mutateFile(operation: string, payload: unknown, schema: ZodType) {
private mutateFile(
operation: MobileWebBridgeOperationName<'task'>,
payload: unknown,
schema: ZodType
) {
return this.requests.request(
'task',
operation,
@@ -1,3 +1,4 @@
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebTaskLinearCommentPayloadSchema,
MobileWebTaskLinearCommentResultSchema,
@@ -122,7 +123,11 @@ export class MobileWebTaskLinearRequestClient extends MobileWebTaskItemRequestCl
)
}
private mutateLinear(operation: string, payload: unknown, schema: ZodType) {
private mutateLinear(
operation: MobileWebBridgeOperationName<'task'>,
payload: unknown,
schema: ZodType
) {
return mutate(schema, this.requests, operation, payload)
}
}
@@ -130,7 +135,7 @@ export class MobileWebTaskLinearRequestClient extends MobileWebTaskItemRequestCl
function mutate(
schema: ZodType,
requests: MobileWebOneShotRequestClient,
operation: string,
operation: MobileWebBridgeOperationName<'task'>,
payload: unknown
) {
return requests.request(
@@ -1,3 +1,4 @@
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebTaskProjectAssignableUsersPayloadSchema,
MobileWebTaskProjectAssignableUsersResultSchema,
@@ -266,7 +267,11 @@ export class MobileWebTaskProjectRequestClient {
return this.mutate('mergeProjectPullRequest', payload, MobileWebTaskProjectMergePayloadSchema)
}
private mutate(operation: string, payload: unknown, payloadSchema: ZodType) {
private mutate(
operation: MobileWebBridgeOperationName<'task'>,
payload: unknown,
payloadSchema: ZodType
) {
return this.requests.request(
'task',
operation,
@@ -1,3 +1,4 @@
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebCreationAgentDetectionPayloadSchema,
MobileWebCreationAgentDetectionResultSchema,
@@ -171,7 +172,7 @@ export class MobileWebWorkspaceCreationRequestClient {
)
}
private availability(operation: string): Promise<boolean> {
private availability(operation: MobileWebBridgeOperationName<'workspace'>): Promise<boolean> {
return this.emptyRequest<{ available: boolean }>(
operation,
MobileWebCreationAvailabilityPayloadSchema,
@@ -180,7 +181,7 @@ export class MobileWebWorkspaceCreationRequestClient {
}
private repoRequest<TResult>(
operation: string,
operation: MobileWebBridgeOperationName<'workspace'>,
payload: MobileWebCreationRepoPayload,
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
): Promise<TResult> {
@@ -194,7 +195,7 @@ export class MobileWebWorkspaceCreationRequestClient {
}
private emptyRequest<TResult>(
operation: string,
operation: MobileWebBridgeOperationName<'workspace'>,
payloadSchema: Parameters<MobileWebOneShotRequestClient['request']>[3],
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
): Promise<TResult> {
@@ -1,4 +1,5 @@
import type { z } from 'zod'
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebCreationBranchSearchResultSchema,
MobileWebCreationGitHubLookupPayloadSchema,
@@ -155,7 +156,7 @@ export class MobileWebWorkspaceCreationSourceRequestClient {
}
private request<TPayload, TResult>(
operation: string,
operation: MobileWebBridgeOperationName<'workspace'>,
payload: TPayload,
payloadSchema: z.ZodType<TPayload>,
resultSchema: z.ZodType<TResult>
+4 -1
View File
@@ -23,7 +23,10 @@ export {
isMobileWebBridgeOperation,
MOBILE_WEB_BRIDGE_OPERATIONS
} from './bridge-operation-registry'
export type { MobileWebBridgeCapability } from './bridge-operation-registry'
export type {
MobileWebBridgeCapability,
MobileWebBridgeOperationName
} from './bridge-operation-registry'
export {
MOBILE_WEB_BRIDGE_ENVELOPE_RESERVE_BYTES,
MOBILE_WEB_BRIDGE_MAX_GRANTS,
@@ -0,0 +1,65 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { MOBILE_WEB_BRIDGE_OPERATIONS } from './bridge-operation-registry'
const REPO_ROOT = resolve(__dirname, '..', '..', '..')
const PAGE_DIR = join(REPO_ROOT, 'src', 'mobile-web', 'src')
const SHELL_DIR = join(REPO_ROOT, 'mobile', 'src', 'mobile-web')
const registered = new Set(
Object.entries(MOBILE_WEB_BRIDGE_OPERATIONS).flatMap(([capability, operations]) =>
operations.map((operation) => `${capability}.${operation}`)
)
)
function sources(dir: string, keep: (name: string) => boolean): { name: string; text: string }[] {
return readdirSync(dir)
.filter((name) => name.endsWith('.ts') && !name.includes('.test.') && keep(name))
.map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') }))
}
describe('mobile web bridge operation registry census', () => {
it('registers every capability and operation pair a page request client names', () => {
const files = sources(PAGE_DIR, () => true)
const named = new Set<string>()
for (const { text } of files) {
for (const match of text.matchAll(/\.request\(\s*'([A-Za-z]+)',\s*'([A-Za-z]+)'/g)) {
named.add(`${match[1]}.${match[2]}`)
}
}
expect(files.length).toBeGreaterThanOrEqual(40)
expect(named.size).toBeGreaterThanOrEqual(150)
expect([...named].filter((pair) => !registered.has(pair))).toEqual([])
})
// A bare `string` here is how `workspace.creationRetiredNames` shipped unregistered: the helper
// erased the operation name before the compiler could check it against the registry.
it('types every page request client operation parameter against the registry', () => {
const files = sources(PAGE_DIR, (name) => name.endsWith('-request-client.ts'))
const untyped = files
.filter(({ text }) => /\boperation\??:\s*string\b/.test(text))
.map(({ name }) => name)
expect(files.map(({ name }) => name)).toContain('mobile-web-one-shot-request-client.ts')
expect(files.length).toBeGreaterThanOrEqual(20)
expect(untyped).toEqual([])
})
it('names every registered operation in a shell module outside the grant tables', () => {
const files = sources(SHELL_DIR, (name) => !name.startsWith('mobile-web-production-'))
const named = new Set<string>()
for (const { text } of files) {
for (const match of text.matchAll(/'([A-Za-z][A-Za-z0-9]*)'/g)) {
named.add(match[1]!)
}
}
const undispatched = [...registered].filter(
(pair) => !named.has(pair.slice(pair.indexOf('.') + 1))
)
expect(files.length).toBeGreaterThanOrEqual(100)
expect(undispatched).toEqual([])
})
})
@@ -9,6 +9,7 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = {
'update',
'remove',
'creationRepositories',
'creationRetiredNames',
'creationSettings',
'creationTrustedHooks',
'creationGitLabAvailability',
@@ -252,6 +253,12 @@ export const MobileWebBridgeCapabilitySchema = z.enum([
export type MobileWebBridgeCapability = z.infer<typeof MobileWebBridgeCapabilitySchema>
/** Operation names a page may request for one capability. Keeps request clients from naming an
* operation the shell never granted. */
export type MobileWebBridgeOperationName<
TCapability extends MobileWebBridgeCapability = MobileWebBridgeCapability
> = (typeof MOBILE_WEB_BRIDGE_OPERATIONS)[TCapability][number]
export function isMobileWebBridgeOperation(
capability: MobileWebBridgeCapability,
operation: string