mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
refactor(mobile): run workspace creation reads over the generic host lane
The page now drives the same desktop requests the native app makes, through an RpcClient-shaped sender over workspace.hostRequest, so the shell's read and source translators and their contracts are gone. Creation itself stays in the shell: a workspace no catalog page has listed has no page handle, and only the authority can mint one. Repo ids cross as host ids, so the re-lookups that existed to re-resolve opaque repo handles are deleted with them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -9,7 +9,12 @@ import { MOBILE_WEB_PRODUCTION_GRANT_INDEX } from './mobile-web-production-grant
|
||||
|
||||
const WORKSPACE = `workspace_0_${'01'.repeat(16)}`
|
||||
|
||||
type Slot = { capability: string; operation: string; payload: unknown }
|
||||
type Slot = {
|
||||
capability: string
|
||||
operation: string
|
||||
payload: unknown
|
||||
mode?: 'subscription'
|
||||
}
|
||||
|
||||
// Distinct operations, so saturation is reached through the shared cap rather than through any
|
||||
// single operation's maxConcurrent. Each one parks on a host call that never settles.
|
||||
@@ -27,11 +32,6 @@ const SATURATION_SLOTS: Slot[] = [
|
||||
{ capability: 'workspace', operation: 'creationDetectAgents', payload: {} },
|
||||
{ capability: 'workspace', operation: 'snapshot', payload: {} },
|
||||
{ capability: 'workspace', operation: 'repositories', payload: {} },
|
||||
{ capability: 'workspace', operation: 'activate', payload: { workspaceId: WORKSPACE } },
|
||||
{ capability: 'workspace', operation: 'remove', payload: { workspaceId: WORKSPACE } },
|
||||
{ capability: 'settings', operation: 'snapshot', payload: {} },
|
||||
{ capability: 'settings', operation: 'update', payload: {} },
|
||||
{ capability: 'account', operation: 'snapshot', payload: {} },
|
||||
{ capability: 'account', operation: 'resetCreditCapability', payload: {} },
|
||||
{ capability: 'task', operation: 'bootstrap', payload: {} },
|
||||
{ capability: 'task', operation: 'repositories', payload: {} },
|
||||
@@ -82,13 +82,7 @@ const SATURATION_SLOTS: Slot[] = [
|
||||
capability: 'nativeChat',
|
||||
operation: 'pendingRead',
|
||||
payload: { workspaceId: WORKSPACE, sessionId: 'provider-session' }
|
||||
},
|
||||
{ capability: 'workspace', operation: 'creationRepositories', payload: {} },
|
||||
{ capability: 'workspace', operation: 'creationSettings', payload: {} },
|
||||
{ capability: 'workspace', operation: 'creationTrustedHooks', payload: {} },
|
||||
{ capability: 'workspace', operation: 'creationGitLabAvailability', payload: {} },
|
||||
{ capability: 'workspace', operation: 'creationLinearAvailability', payload: {} },
|
||||
{ capability: 'workspace', operation: 'creationRuntimeCapabilities', payload: {} }
|
||||
}
|
||||
]
|
||||
|
||||
// Held out of the fill so the overflow probe is an operation with its own budget untouched.
|
||||
@@ -109,19 +103,20 @@ describe('mobile web capability broker backpressure', () => {
|
||||
expect(harness.messages.slice(before)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a new operation once the shared pending-request cap is saturated', async () => {
|
||||
// The shared 64-request budget is no longer reachable from live one-shot operations: the wave
|
||||
// moved most of them onto the generic lane, whose single grant caps at its own concurrency. What
|
||||
// stays provable here is that each operation is held to its own budget; the shared ceiling is
|
||||
// covered directly in mobile-web-subscription-capacity.test.ts.
|
||||
it('holds every operation to its own concurrency budget while filling the page', async () => {
|
||||
const harness = await createSaturatedHarness()
|
||||
|
||||
expect(harness.accepted).toBe(MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS)
|
||||
|
||||
await harness.send(OVERFLOW_SLOT, 'Z'.repeat(22))
|
||||
|
||||
expect(harness.messages.at(-1)).toMatchObject({
|
||||
type: 'response',
|
||||
requestId: 'Z'.repeat(22),
|
||||
status: 'error',
|
||||
error: { code: 'rate_limited', retryable: true }
|
||||
})
|
||||
expect(harness.accepted).toBeGreaterThan(0)
|
||||
expect(harness.accepted).toBeLessThanOrEqual(MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS)
|
||||
for (const [index, slot] of harness.slots.entries()) {
|
||||
const budget = requiredGrant(`${slot.capability}.${slot.operation}`).limits.maxConcurrent
|
||||
expect(harness.used[index]).toBeLessThanOrEqual(budget)
|
||||
}
|
||||
expect(harness.used.some((taken, index) => taken === harness.budgets[index])).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('refuses a host payload larger than the operation response budget', async () => {
|
||||
@@ -197,7 +192,15 @@ async function createSaturatedHarness() {
|
||||
accepted += 1
|
||||
}
|
||||
}
|
||||
return { ...harness, accepted, used }
|
||||
return {
|
||||
...harness,
|
||||
accepted,
|
||||
used,
|
||||
slots,
|
||||
budgets: slots.map(
|
||||
(slot) => requiredGrant(`${slot.capability}.${slot.operation}`).limits.maxConcurrent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(nativeAuthority: Record<string, unknown> = {}) {
|
||||
@@ -225,7 +228,19 @@ function createHarness(nativeAuthority: Record<string, unknown> = {}) {
|
||||
now: () => 1000
|
||||
})
|
||||
const send = async (slot: Slot, requestId: string): Promise<void> => {
|
||||
void broker.handle(mobileWebBridgeRequestMessage({ requestId, ...slot }))
|
||||
const subscription =
|
||||
slot.mode === 'subscription'
|
||||
? { mode: 'subscription' as const, subscriptionId: requestId.replaceAll('A', 'B') }
|
||||
: {}
|
||||
void broker.handle(
|
||||
mobileWebBridgeRequestMessage({
|
||||
requestId,
|
||||
capability: slot.capability,
|
||||
operation: slot.operation,
|
||||
payload: slot.payload,
|
||||
...subscription
|
||||
})
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
return { broker, messages, sendRequest, subscribe, send }
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('mobile web capability dispatch census', () => {
|
||||
})
|
||||
|
||||
expect(unresolved.map(({ capability, operation }) => `${capability}.${operation}`)).toEqual([])
|
||||
expect(registeredOperations()).toHaveLength(189)
|
||||
expect(registeredOperations()).toHaveLength(165)
|
||||
})
|
||||
|
||||
it('carries a dispatch arm for exactly the capabilities that own operations of that mode', () => {
|
||||
|
||||
@@ -36,8 +36,7 @@ const REAUTHORIZATION_SITES: Record<string, number> = {
|
||||
'mobile-web-task-item-file-operations.ts': 1,
|
||||
'mobile-web-task-item-mutation-operations.ts': 1,
|
||||
'mobile-web-task-item-review-operations.ts': 1,
|
||||
'mobile-web-task-project-mutation-operations.ts': 1,
|
||||
'mobile-web-workspace-creation-create-operations.ts': 2
|
||||
'mobile-web-task-project-mutation-operations.ts': 1
|
||||
}
|
||||
|
||||
// Device-only mutations and handles consumed in one awaited call have no reauthorization window.
|
||||
@@ -80,10 +79,7 @@ const NO_REAUTHORIZATION_WINDOW: readonly string[] = [
|
||||
'task.updateResume',
|
||||
'task.updateSettings',
|
||||
'terminal.attachImage',
|
||||
'terminal.clipboardPaste',
|
||||
'workspace.creationPersistTrust',
|
||||
'workspace.creationSaveSparsePreset',
|
||||
'workspace.creationSshConnect'
|
||||
'terminal.clipboardPaste'
|
||||
]
|
||||
|
||||
function shellSources(): Map<string, string> {
|
||||
@@ -163,7 +159,7 @@ describe('mobile web mutation reauthorization census', () => {
|
||||
}
|
||||
|
||||
expect(unaccounted).toEqual([])
|
||||
expect(mutations()).toHaveLength(108)
|
||||
expect(mutations()).toHaveLength(105)
|
||||
})
|
||||
|
||||
it('exempts only registered mutations', () => {
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
import { capabilityGrants, grantLimits } from './mobile-web-production-grant-table'
|
||||
|
||||
export const MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS = capabilityGrants('workspace', {
|
||||
creationRepositories: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationRetiredNames: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSettings: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationTrustedHooks: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationGitLabAvailability: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationLinearAvailability: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSshState: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSshConnect: grantLimits(1 * 1024, 128 * 1024, 1, 3, 0.25),
|
||||
creationDetectAgents: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationRepoHooks: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationRuntimeCapabilities: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSparsePresets: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSaveSparsePreset: grantLimits(64 * 1024, 128 * 1024, 1, 8, 2),
|
||||
creationPersistTrust: grantLimits(128 * 1024, 128 * 1024, 1, 4, 0.5),
|
||||
creationSearchGitHub: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSearchGitLab: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSearchLinear: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationSearchBranches: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationResolveRepoSlug: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationLookupGitHub: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationLookupGitHubRepo: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationLookupGitLab: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationResolvePrBase: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationResolveMrBase: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
|
||||
creationCreateBlank: grantLimits(64 * 1024, 2 * 1024, 1, 2, 0.1),
|
||||
creationCreateFromSource: grantLimits(64 * 1024, 2 * 1024, 1, 2, 0.1)
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('mobile web shell response schema corpus', () => {
|
||||
(grant) => grant.operation === 'subscribe'
|
||||
)
|
||||
|
||||
expect(resultSchemas.length).toBeGreaterThanOrEqual(150)
|
||||
expect(resultSchemas.length).toBeGreaterThanOrEqual(140)
|
||||
expect(oneShotGrants.length).toBeGreaterThan(100)
|
||||
expect(eventSchemas.length).toBeGreaterThanOrEqual(subscriptionGrants.length)
|
||||
expect(MOBILE_WEB_PRODUCTION_GRANTS).toHaveLength(registeredOperations.length)
|
||||
|
||||
@@ -146,10 +146,12 @@ export class MobileWebWorkspaceAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkspace(hostWorkspaceId: string, hostRepoId: string): string {
|
||||
registerWorkspace(hostWorkspaceId: string, hostRepoId?: string): string {
|
||||
this.rememberWorkspace(hostWorkspaceId)
|
||||
this.hostRepoIdByHostWorkspaceId.set(hostWorkspaceId, hostRepoId)
|
||||
this.rememberRepo(hostRepoId)
|
||||
if (hostRepoId !== undefined) {
|
||||
this.hostRepoIdByHostWorkspaceId.set(hostWorkspaceId, hostRepoId)
|
||||
this.rememberRepo(hostRepoId)
|
||||
}
|
||||
return this.pageWorkspaceId(hostWorkspaceId)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,68 +4,33 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
|
||||
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
const REPO_ID = 'repo-1'
|
||||
|
||||
function hostClient(overrides: Record<string, unknown> = {}) {
|
||||
return vi.fn(async (method: string) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return { ok: true, result: { worktree: { id: '/host/worktree-secret' } } }
|
||||
}
|
||||
if (method in overrides) {
|
||||
return overrides[method]
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
})
|
||||
}
|
||||
|
||||
describe('mobile web workspace creation writes', () => {
|
||||
it('revalidates a PR and its fork base natively before creating', async () => {
|
||||
it('sends the page selection unchanged and answers with a fresh page handle', async () => {
|
||||
const authority = workspaceAuthority()
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
const pageRepoId = authority.pageRepoId('host-repo-secret')
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (method === 'github.workItem') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
id: 'provider-secret-id',
|
||||
type: 'pr',
|
||||
number: 7,
|
||||
title: 'Authoritative title',
|
||||
state: 'open',
|
||||
url: 'https://github.example.com/acme/orca/pull/7',
|
||||
labels: [],
|
||||
updatedAt: '2026-07-23T00:00:00Z',
|
||||
author: null,
|
||||
branchName: 'authoritative-head',
|
||||
baseRefName: 'main',
|
||||
isCrossRepository: true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.resolvePrBase') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
baseBranch: 'refs/pull/7/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: {
|
||||
remoteName: 'contributor',
|
||||
branchName: 'authoritative-head',
|
||||
remoteUrl: 'git@github.example.com:contributor/orca.git'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { settings: { agentCmdOverrides: { codex: 'TOKEN=secret codex' } } }
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: { id: '/host/worktree-secret' },
|
||||
warning: 'Setup completed with a warning.'
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
})
|
||||
const sendRequest = hostClient()
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationCreateOperation({
|
||||
operation: 'creationCreateFromSource',
|
||||
@@ -76,89 +41,109 @@ describe('mobile web workspace creation writes', () => {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 7,
|
||||
title: 'Tampered page title',
|
||||
url: 'https://github.example.com/attacker/fake/pull/7',
|
||||
repoId: pageRepoId
|
||||
title: 'Bridge title',
|
||||
url: 'https://github.example.com/acme/orca/pull/7',
|
||||
repoId: REPO_ID
|
||||
},
|
||||
baseBranch: 'attacker/base',
|
||||
compareBaseRef: 'attacker/compare',
|
||||
pushTarget: { remoteName: 'attacker', branchName: 'attacker-branch' }
|
||||
baseBranch: 'refs/pull/7/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: { remoteName: 'contributor', branchName: 'head' }
|
||||
},
|
||||
targetRepoId: pageRepoId,
|
||||
targetRepoId: REPO_ID,
|
||||
setupDecision: 'skip',
|
||||
agentChoice: 'codex',
|
||||
sparseCheckout: {
|
||||
directories: ['src/renderer'],
|
||||
presetId: 'renderer'
|
||||
}
|
||||
sparseCheckout: { directories: ['src/renderer'], presetId: 'renderer' }
|
||||
},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('github.workItem', {
|
||||
repo: 'id:host-repo-secret',
|
||||
number: 7
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.resolvePrBase',
|
||||
{
|
||||
repo: 'id:host-repo-secret',
|
||||
prNumber: 7,
|
||||
headRefName: 'authoritative-head',
|
||||
baseRefName: 'main',
|
||||
isCrossRepository: true
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
// The page resolved the base through the same shared operations, so nothing is looked up twice.
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain('github.workItem')
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain('worktree.resolvePrBase')
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:host-repo-secret',
|
||||
repo: `id:${REPO_ID}`,
|
||||
baseBranch: 'refs/pull/7/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: {
|
||||
remoteName: 'contributor',
|
||||
branchName: 'authoritative-head',
|
||||
remoteUrl: 'git@github.example.com:contributor/orca.git'
|
||||
},
|
||||
startupDraft: 'https://github.example.com/acme/orca/pull/7',
|
||||
createdWithAgent: 'codex',
|
||||
sparseCheckout: {
|
||||
directories: ['src/renderer'],
|
||||
presetId: 'renderer'
|
||||
}
|
||||
sparseCheckout: { directories: ['src/renderer'], presetId: 'renderer' }
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
expect(result).toEqual({
|
||||
workspaceId: expect.stringMatching(/^workspace_/),
|
||||
name: 'pr-7',
|
||||
warning: 'Setup completed with a warning.'
|
||||
name: 'pr-7'
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/host|secret|provider/)
|
||||
expect(JSON.stringify(result)).not.toContain('/host/worktree-secret')
|
||||
expect(authority.hostWorkspaceId((result as { workspaceId: string }).workspaceId)).toBe(
|
||||
'/host/worktree-secret'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a page-supplied native repository ID', async () => {
|
||||
it('rebuilds a Linear source from its identifier because the wire carries only that', async () => {
|
||||
const authority = workspaceAuthority()
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
const sendRequest = hostClient({
|
||||
'linear.searchIssues': {
|
||||
ok: true,
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
id: 'linear-1',
|
||||
identifier: 'STA-42',
|
||||
title: 'Authoritative title',
|
||||
url: 'https://linear.app/orca/issue/STA-42',
|
||||
branchName: 'sta-42-authoritative',
|
||||
updatedAt: '2026-07-23T00:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await executeMobileWebWorkspaceCreationCreateOperation({
|
||||
operation: 'creationCreateFromSource',
|
||||
payload: {
|
||||
selection: {
|
||||
kind: 'work-item',
|
||||
item: {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Tampered page title',
|
||||
url: 'https://linear.app/attacker/issue/STA-42',
|
||||
linearIdentifier: 'STA-42'
|
||||
}
|
||||
},
|
||||
targetRepoId: REPO_ID,
|
||||
setupDecision: 'skip',
|
||||
agentChoice: 'blank'
|
||||
},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority
|
||||
})
|
||||
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).toContain('linear.searchIssues')
|
||||
const create = sendRequest.mock.calls.find(([method]) => method === 'worktree.create')!
|
||||
expect(JSON.stringify(create[1])).not.toContain('Tampered')
|
||||
})
|
||||
|
||||
it('refuses an agent choice the host does not define', async () => {
|
||||
await expect(
|
||||
executeMobileWebWorkspaceCreationCreateOperation({
|
||||
operation: 'creationCreateBlank',
|
||||
payload: blankPayload('host-repo-secret'),
|
||||
client: {
|
||||
sendRequest: vi.fn().mockResolvedValue({ ok: true, result: { capabilities: [] } })
|
||||
} as unknown as RpcClient,
|
||||
authority
|
||||
payload: { ...blankPayload(), agentChoice: 'not-an-agent' },
|
||||
client: { sendRequest: hostClient() } as unknown as RpcClient,
|
||||
authority: workspaceAuthority()
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
).rejects.toMatchObject({ code: 'invalid_request' })
|
||||
})
|
||||
})
|
||||
|
||||
function blankPayload(repoId: string) {
|
||||
function blankPayload() {
|
||||
return {
|
||||
repoId,
|
||||
repoId: REPO_ID,
|
||||
baseName: 'secure-workspace',
|
||||
nameWasGenerated: false,
|
||||
agentChoice: 'blank',
|
||||
|
||||
@@ -5,18 +5,16 @@ import {
|
||||
type MobileWebCreationSelection
|
||||
} from '../../../src/shared/mobile-web/workspace-creation-create-contract'
|
||||
import { buildLinearWorkspaceSource } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
|
||||
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
|
||||
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import {
|
||||
mobileWebHostRepoIdFromHost,
|
||||
type MobileWebWorkspaceAuthority
|
||||
} from './mobile-web-workspace-authority'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
/** Creation stays shell-side for one reason: a workspace no catalog page has listed yet has no
|
||||
* page handle, and only the authority can mint one. Everything the page can address by host id it
|
||||
* already sends, so nothing here re-resolves what the page looked up. */
|
||||
export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
|
||||
operation: string
|
||||
payload: unknown
|
||||
@@ -27,163 +25,52 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
|
||||
if (args.operation === 'creationCreateBlank') {
|
||||
const payload = MobileWebCreationBlankPayloadSchema.parse(args.payload)
|
||||
const capabilities = await operations.readRuntimeCapabilities()
|
||||
const hostRepoId = args.authority.hostRepoId(payload.repoId)
|
||||
const result = await operations.createBlankWorkspace({
|
||||
...payload,
|
||||
repoId: hostRepoId,
|
||||
agentChoice: requiredAgentChoice(payload.agentChoice),
|
||||
comment: payload.comment,
|
||||
worktreeCreateIdempotency: capabilities.worktreeCreateIdempotency
|
||||
})
|
||||
return presentCreatedWorkspace(result, hostRepoId, args.authority)
|
||||
return presentCreatedWorkspace(result, args.authority)
|
||||
}
|
||||
if (args.operation === 'creationCreateFromSource') {
|
||||
const payload = MobileWebCreationFromSourcePayloadSchema.parse(args.payload)
|
||||
const capabilities = await operations.readRuntimeCapabilities()
|
||||
const hostRepoId = args.authority.hostRepoId(payload.targetRepoId)
|
||||
const selection = await authoritativeSelection(payload.selection, operations, args.authority)
|
||||
args.authority.assertHostRepoBinding(payload.targetRepoId, hostRepoId)
|
||||
assertSelectionRepoBinding(payload.selection, selection, args.authority)
|
||||
const result = await operations.createWorkspaceFromSource({
|
||||
...payload,
|
||||
selection,
|
||||
targetRepoId: hostRepoId,
|
||||
selection: await hostSelection(payload.selection, operations),
|
||||
agentChoice: requiredAgentChoice(payload.agentChoice),
|
||||
workspaceName: payload.workspaceName,
|
||||
note: payload.note,
|
||||
sparseCheckout: payload.sparseCheckout,
|
||||
worktreeCreateIdempotency: capabilities.worktreeCreateIdempotency
|
||||
})
|
||||
return presentCreatedWorkspace(result, hostRepoId, args.authority)
|
||||
return presentCreatedWorkspace(result, args.authority)
|
||||
}
|
||||
throw new MobileWebBrokerError('unsupported_capability')
|
||||
}
|
||||
|
||||
function assertSelectionRepoBinding(
|
||||
pageSelection: MobileWebCreationSelection,
|
||||
hostSelection: MobileComposerCreateSelection,
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
): void {
|
||||
if (pageSelection.kind === 'work-item' && pageSelection.item.provider !== 'linear') {
|
||||
if (
|
||||
hostSelection.kind !== 'work-item' ||
|
||||
hostSelection.item.provider === 'linear' ||
|
||||
!hostSelection.item.repoId
|
||||
) {
|
||||
throw new MobileWebBrokerError('conflict')
|
||||
}
|
||||
authority.assertHostRepoBinding(
|
||||
pageSelection.item.repoId,
|
||||
mobileWebHostRepoIdFromHost(hostSelection.item.repoId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function authoritativeSelection(
|
||||
/** The wire carries a Linear issue as its identifier only, so the full source is rebuilt here.
|
||||
* GitHub and GitLab items cross whole and are used as sent. */
|
||||
async function hostSelection(
|
||||
selection: MobileWebCreationSelection,
|
||||
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>,
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
|
||||
): Promise<MobileComposerCreateSelection> {
|
||||
if (selection.kind !== 'work-item') {
|
||||
if (selection.kind !== 'work-item' || selection.item.provider !== 'linear') {
|
||||
return selection
|
||||
}
|
||||
if (selection.item.provider === 'linear') {
|
||||
const linearIdentifier = selection.item.linearIdentifier
|
||||
const issues = await operations.searchLinearIssues(linearIdentifier, undefined)
|
||||
const issue = issues.find(
|
||||
(candidate) => candidate.identifier.toLowerCase() === linearIdentifier.toLowerCase()
|
||||
)
|
||||
if (!issue) {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
return {
|
||||
kind: 'work-item',
|
||||
item: buildLinearWorkspaceSource(issue),
|
||||
branchNameOverride: selection.branchNameOverride
|
||||
}
|
||||
const linearIdentifier = selection.item.linearIdentifier
|
||||
const issues = await operations.searchLinearIssues(linearIdentifier, undefined)
|
||||
const issue = issues.find(
|
||||
(candidate) => candidate.identifier.toLowerCase() === linearIdentifier.toLowerCase()
|
||||
)
|
||||
if (!issue) {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
const hostRepoId = authority.hostRepoId(selection.item.repoId)
|
||||
if (selection.item.provider === 'github') {
|
||||
const item = await operations.lookupGitHubItem(hostRepoId, selection.item.number)
|
||||
requireGitHubIdentity(item, selection.item.type)
|
||||
const base =
|
||||
item.type === 'pr'
|
||||
? await operations.resolvePrBase({
|
||||
repoId: hostRepoId,
|
||||
prNumber: item.number,
|
||||
headRefName: item.branchName,
|
||||
baseRefName: item.baseRefName,
|
||||
isCrossRepository: item.isCrossRepository
|
||||
})
|
||||
: {}
|
||||
return {
|
||||
kind: 'work-item',
|
||||
item: linkedGitHubItem(item, hostRepoId),
|
||||
...base
|
||||
}
|
||||
}
|
||||
const item = await operations.lookupGitLabItemByPath({
|
||||
repoId: hostRepoId,
|
||||
host: new URL(selection.item.url).host,
|
||||
path: gitLabProjectPath(selection.item.url),
|
||||
iid: selection.item.number,
|
||||
type: selection.item.type
|
||||
})
|
||||
requireGitLabIdentity(item, selection.item.type)
|
||||
const base =
|
||||
item.type === 'mr'
|
||||
? await operations.resolveMrBase({
|
||||
repoId: hostRepoId,
|
||||
mrIid: item.number,
|
||||
sourceBranch: item.branchName,
|
||||
targetBranch: item.baseRefName,
|
||||
isCrossRepository: item.isCrossRepository
|
||||
})
|
||||
: {}
|
||||
return {
|
||||
kind: 'work-item',
|
||||
item: linkedGitLabItem(item, hostRepoId),
|
||||
...base
|
||||
}
|
||||
}
|
||||
|
||||
function linkedGitHubItem(item: GitHubWorkItem, repoId: string) {
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId
|
||||
}
|
||||
}
|
||||
|
||||
function linkedGitLabItem(item: GitLabWorkItem, repoId: string) {
|
||||
return {
|
||||
provider: 'gitlab' as const,
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId
|
||||
}
|
||||
}
|
||||
|
||||
function requireGitHubIdentity(
|
||||
item: GitHubWorkItem | null,
|
||||
type: 'issue' | 'pr'
|
||||
): asserts item is GitHubWorkItem {
|
||||
if (!item || item.type !== type) {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
}
|
||||
|
||||
function requireGitLabIdentity(
|
||||
item: GitLabWorkItem | null,
|
||||
type: 'issue' | 'mr'
|
||||
): asserts item is GitLabWorkItem {
|
||||
if (!item || item.type !== type) {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
item: buildLinearWorkspaceSource(issue),
|
||||
branchNameOverride: selection.branchNameOverride
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,25 +82,15 @@ function requiredAgentChoice(value: string) {
|
||||
return choice
|
||||
}
|
||||
|
||||
function gitLabProjectPath(value: string): string {
|
||||
const path = new URL(value).pathname
|
||||
const marker = path.indexOf('/-/')
|
||||
if (marker <= 0) {
|
||||
throw new MobileWebBrokerError('invalid_request')
|
||||
}
|
||||
return path.slice(1, marker)
|
||||
}
|
||||
|
||||
function presentCreatedWorkspace(
|
||||
result: { worktreeId: string; name: string; warning?: string } | { error: string },
|
||||
hostRepoId: string,
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
): unknown {
|
||||
if ('error' in result) {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
}
|
||||
return MobileWebCreationResultSchema.parse({
|
||||
workspaceId: authority.registerWorkspace(result.worktreeId, hostRepoId),
|
||||
workspaceId: authority.registerWorkspace(result.worktreeId),
|
||||
name: result.name,
|
||||
warning: result.warning
|
||||
})
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
|
||||
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
describe('mobile web workspace creation provider revalidation', () => {
|
||||
it('repeats GitLab MR lookup and base resolution with native repository authority', async () => {
|
||||
const { authority, pageRepoId } = repositoryAuthority()
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'status.get') {
|
||||
return { ok: true, result: { capabilities: [] } }
|
||||
}
|
||||
if (method === 'gitlab.workItemByPath') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
id: 'gitlab-durable-secret',
|
||||
type: 'mr',
|
||||
number: 9,
|
||||
title: 'Authoritative MR',
|
||||
state: 'opened',
|
||||
url: 'https://gitlab.example.com/group/orca/-/merge_requests/9',
|
||||
labels: [],
|
||||
updatedAt: '2026-07-23T00:00:00Z',
|
||||
author: null,
|
||||
branchName: 'trusted-head',
|
||||
baseRefName: 'main',
|
||||
isCrossRepository: true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.resolveMrBase') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
baseBranch: 'refs/merge-requests/9/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: {
|
||||
remoteName: 'contributor',
|
||||
branchName: 'trusted-head',
|
||||
remoteUrl: 'git@gitlab.example.com:contributor/orca.git'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return { ok: true, result: { worktree: { id: '/host/gitlab-worktree' } } }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
})
|
||||
|
||||
const result = await createFromSource({
|
||||
authority,
|
||||
sendRequest,
|
||||
pageRepoId,
|
||||
selection: {
|
||||
kind: 'work-item',
|
||||
item: {
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 9,
|
||||
title: 'Page title',
|
||||
url: 'https://gitlab.example.com/group/orca/-/merge_requests/9',
|
||||
repoId: pageRepoId
|
||||
},
|
||||
baseBranch: 'page/base'
|
||||
}
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('gitlab.workItemByPath', {
|
||||
repo: 'id:host-repo-secret',
|
||||
host: 'gitlab.example.com',
|
||||
path: 'group/orca',
|
||||
iid: 9,
|
||||
type: 'mr'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.resolveMrBase',
|
||||
{
|
||||
repo: 'id:host-repo-secret',
|
||||
mrIid: 9,
|
||||
sourceBranch: 'trusted-head',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:host-repo-secret',
|
||||
baseBranch: 'refs/merge-requests/9/head',
|
||||
linkedGitLabMR: 9
|
||||
}),
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
workspaceId: expect.stringMatching(/^workspace_/),
|
||||
name: 'mr-9'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-searches Linear by exact identifier and ignores page metadata', async () => {
|
||||
const { authority, pageRepoId } = repositoryAuthority()
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'status.get') {
|
||||
return { ok: true, result: { capabilities: [] } }
|
||||
}
|
||||
if (method === 'linear.searchIssues') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
items: [
|
||||
linearIssue('OTHER-1', 'Wrong issue'),
|
||||
linearIssue('ENG-42', 'Authoritative Linear title')
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return { ok: true, result: { worktree: { id: '/host/linear-worktree' } } }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
})
|
||||
|
||||
const result = await createFromSource({
|
||||
authority,
|
||||
sendRequest,
|
||||
pageRepoId,
|
||||
selection: {
|
||||
kind: 'work-item',
|
||||
item: {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Page-controlled title',
|
||||
url: 'https://linear.app/attacker/issue/ENG-42/fake',
|
||||
linearIdentifier: 'eng-42'
|
||||
},
|
||||
branchNameOverride: 'trusted-user-override'
|
||||
}
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('linear.searchIssues', {
|
||||
query: 'eng-42',
|
||||
limit: 50,
|
||||
workspaceId: undefined
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:host-repo-secret',
|
||||
linkedLinearIssue: 'ENG-42',
|
||||
linkedLinearIssueWorkspaceId: 'linear-workspace-secret',
|
||||
displayName: 'ENG-42 Authoritative Linear title',
|
||||
linkedLinearIssueOrganizationUrlKey: 'acme'
|
||||
}),
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
workspaceId: expect.stringMatching(/^workspace_/),
|
||||
name: 'eng-42'
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/host|secret|linear-workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
function createFromSource(args: {
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
sendRequest: ReturnType<typeof vi.fn>
|
||||
pageRepoId: string
|
||||
selection: unknown
|
||||
}) {
|
||||
return executeMobileWebWorkspaceCreationCreateOperation({
|
||||
operation: 'creationCreateFromSource',
|
||||
payload: {
|
||||
selection: args.selection,
|
||||
targetRepoId: args.pageRepoId,
|
||||
setupDecision: 'skip',
|
||||
agentChoice: 'blank'
|
||||
},
|
||||
client: { sendRequest: args.sendRequest } as unknown as RpcClient,
|
||||
authority: args.authority
|
||||
})
|
||||
}
|
||||
|
||||
function repositoryAuthority() {
|
||||
const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(6))
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
return { authority, pageRepoId: authority.pageRepoId('host-repo-secret') }
|
||||
}
|
||||
|
||||
function linearIssue(identifier: string, title: string) {
|
||||
return {
|
||||
id: `linear-id-${identifier}`,
|
||||
workspaceId: 'linear-workspace-secret',
|
||||
identifier,
|
||||
title,
|
||||
branchName: `branch-${identifier}`,
|
||||
url: `https://linear.app/acme/issue/${identifier}/authoritative`,
|
||||
state: { name: 'Todo', type: 'unstarted', color: '#737373' },
|
||||
team: { id: 'team-secret', name: 'Engineering', key: 'ENG' },
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
priority: 1,
|
||||
updatedAt: '2026-07-23T00:00:00Z'
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { executeMobileWebWorkspaceCreationReadOperation } from './mobile-web-workspace-creation-read-operations'
|
||||
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
function authority(): MobileWebWorkspaceAuthority {
|
||||
return new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(7))
|
||||
}
|
||||
|
||||
describe('mobile web workspace creation reads', () => {
|
||||
it('returns presentation data with opaque repository, project, and execution authority', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [
|
||||
{
|
||||
id: '/host/repo-id',
|
||||
displayName: 'Orca',
|
||||
path: '/Users/private/orca',
|
||||
connectionId: 'ssh-secret-target',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'acme', repo: 'orca' },
|
||||
gitRemoteIdentity: { remoteUrl: 'git@secret.example:acme/orca.git' }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const workspaceAuthority = authority()
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationRepositories',
|
||||
payload: {},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
repositories: [
|
||||
{
|
||||
id: expect.stringMatching(/^repo_/),
|
||||
displayName: 'Orca',
|
||||
path: '/Users/private/orca',
|
||||
connectionId: expect.stringMatching(/^repo_/),
|
||||
executionHostId: expect.stringMatching(/^ssh:executionHost_/),
|
||||
executionHostLabel: 'Host',
|
||||
projectId: expect.stringMatching(/^project_/),
|
||||
upstream: { owner: 'acme', repo: 'orca' },
|
||||
kind: 'git'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('ssh-secret-target')
|
||||
expect(JSON.stringify(result)).not.toContain('/host/repo-id')
|
||||
expect(JSON.stringify(result)).not.toContain('secret.example')
|
||||
})
|
||||
|
||||
it('resolves the opaque repository to native SSH authority and sanitizes errors', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [
|
||||
{
|
||||
id: 'host-repo',
|
||||
displayName: 'Remote',
|
||||
path: '/remote/private',
|
||||
connectionId: 'host-ssh-target'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
state: {
|
||||
targetId: 'host-ssh-target',
|
||||
status: 'error',
|
||||
error: 'private-key /Users/private/.ssh/id_ed25519 rejected',
|
||||
reconnectAttempt: 2,
|
||||
connectionGeneration: 99
|
||||
}
|
||||
}
|
||||
})
|
||||
const workspaceAuthority = authority()
|
||||
const repositories = (await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationRepositories',
|
||||
payload: {},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})) as { repositories: { id: string }[] }
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationSshState',
|
||||
payload: { repoId: repositories.repositories[0]!.id },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('ssh.getState', {
|
||||
targetId: 'host-ssh-target'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
targetId: repositories.repositories[0]!.id,
|
||||
status: 'error',
|
||||
error: 'SSH connection failed.',
|
||||
reconnectAttempt: 2
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('private-key')
|
||||
expect(JSON.stringify(result)).not.toContain('connectionGeneration')
|
||||
})
|
||||
|
||||
it('reads retired names through opaque repository authority', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'host-repo', displayName: 'Orca', path: '/workspace/orca' }] }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
retiredNamesByRepo: { 'host-repo': ['nautilus'] },
|
||||
retiredNameTiersByRepo: { 'host-repo': 2 }
|
||||
}
|
||||
})
|
||||
const workspaceAuthority = authority()
|
||||
const listed = (await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationRepositories',
|
||||
payload: {},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})) as { repositories: { id: string }[] }
|
||||
const repoId = listed.repositories[0]!.id
|
||||
|
||||
await expect(
|
||||
executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationRetiredNames',
|
||||
payload: { repoId },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})
|
||||
).resolves.toEqual({ exhaustedTiers: 2, names: ['nautilus'] })
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('worktree.listRetiredNames', {
|
||||
repo: 'id:host-repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('never returns configured launch commands with page settings', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
settings: {
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude'],
|
||||
visibleTaskProviders: ['github', 'invalid'],
|
||||
agentCmdOverrides: { codex: 'TOKEN=secret codex' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationSettings',
|
||||
payload: {},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: authority()
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude'],
|
||||
visibleTaskProviders: ['github']
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('keeps sparse presets behind opaque repository authority', async () => {
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [{ id: 'host-repo', displayName: 'Orca', path: '/private/orca' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
const preset = {
|
||||
id: 'preset-1',
|
||||
repoId: 'host-repo',
|
||||
name: 'Mobile',
|
||||
directories: ['mobile'],
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
return method === 'repo.sparsePresets'
|
||||
? { ok: true, result: { presets: [preset] } }
|
||||
: { ok: true, result: { preset } }
|
||||
})
|
||||
const workspaceAuthority = authority()
|
||||
const repositories = (await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationRepositories',
|
||||
payload: {},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})) as { repositories: { id: string }[] }
|
||||
const repoId = repositories.repositories[0]!.id
|
||||
|
||||
const listed = await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationSparsePresets',
|
||||
payload: { repoId },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})
|
||||
const saved = await executeMobileWebWorkspaceCreationReadOperation({
|
||||
operation: 'creationSaveSparsePreset',
|
||||
payload: { repoId, id: 'preset-1', name: 'Mobile', directories: ['mobile'] },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority: workspaceAuthority
|
||||
})
|
||||
|
||||
expect(listed).toMatchObject({ presets: [{ repoId, id: 'preset-1' }] })
|
||||
expect(saved).toMatchObject({ preset: { repoId, id: 'preset-1' } })
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.sparsePresets', { repo: 'id:host-repo' })
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.saveSparsePreset', {
|
||||
repo: 'id:host-repo',
|
||||
id: 'preset-1',
|
||||
name: 'Mobile',
|
||||
directories: ['mobile']
|
||||
})
|
||||
expect(JSON.stringify([listed, saved])).not.toContain('host-repo')
|
||||
})
|
||||
})
|
||||
@@ -1,221 +0,0 @@
|
||||
import {
|
||||
MobileWebCreationAgentDetectionPayloadSchema,
|
||||
MobileWebCreationAgentDetectionResultSchema,
|
||||
MobileWebCreationAvailabilityPayloadSchema,
|
||||
MobileWebCreationAvailabilityResultSchema,
|
||||
MobileWebCreationPersistTrustPayloadSchema,
|
||||
MobileWebCreationRepoHooksResultSchema,
|
||||
MobileWebCreationRepoPayloadSchema,
|
||||
MobileWebCreationRepositoriesPayloadSchema,
|
||||
MobileWebCreationRepositoriesResultSchema,
|
||||
MobileWebCreationRetiredNamesResultSchema,
|
||||
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
|
||||
MobileWebCreationRuntimeCapabilitiesResultSchema,
|
||||
MobileWebCreationSettingsPayloadSchema,
|
||||
MobileWebCreationSettingsResultSchema,
|
||||
MobileWebCreationSparsePresetSavePayloadSchema,
|
||||
MobileWebCreationSparsePresetSaveResultSchema,
|
||||
MobileWebCreationSparsePresetsResultSchema,
|
||||
MobileWebCreationSshStateResultSchema,
|
||||
MobileWebCreationTrustedHooksPayloadSchema,
|
||||
MobileWebCreationTrustedHooksResultSchema
|
||||
} from '../../../src/shared/mobile-web/workspace-creation-read-contract'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getExecutionHostLabel,
|
||||
parseExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
export async function executeMobileWebWorkspaceCreationReadOperation(args: {
|
||||
operation: string
|
||||
payload: unknown
|
||||
client: RpcClient
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
}): Promise<unknown> {
|
||||
const operations = nativeHostWorkspaceCreationOperations(args.client)
|
||||
if (args.operation === 'creationRepositories') {
|
||||
MobileWebCreationRepositoriesPayloadSchema.parse(args.payload)
|
||||
const repositories = await operations.listRepositories()
|
||||
args.authority.synchronizeCreationRepositories(repositories)
|
||||
return MobileWebCreationRepositoriesResultSchema.parse({
|
||||
repositories: repositories.map((repo) => {
|
||||
const id = args.authority.pageRepoId(repo.id)
|
||||
const executionHostId = getRepoExecutionHostId(repo)
|
||||
return {
|
||||
id,
|
||||
displayName: repo.displayName,
|
||||
path: repo.path,
|
||||
...(repo.badgeColor ? { badgeColor: repo.badgeColor } : {}),
|
||||
connectionId: repo.connectionId ? id : null,
|
||||
executionHostId: args.authority.pageExecutionHostId(executionHostId),
|
||||
executionHostLabel: pageExecutionHostLabel(executionHostId),
|
||||
projectId: args.authority.pageProjectId(getProjectIdentityKey(repo)),
|
||||
...(repo.upstream
|
||||
? {
|
||||
upstream: {
|
||||
owner: repo.upstream.owner,
|
||||
repo: repo.upstream.repo,
|
||||
...(repo.upstream.host ? { host: repo.upstream.host } : {})
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(repo.kind ? { kind: repo.kind } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationSettings') {
|
||||
MobileWebCreationSettingsPayloadSchema.parse(args.payload)
|
||||
const settings = await operations.readRuntimeSettings()
|
||||
return MobileWebCreationSettingsResultSchema.parse({
|
||||
defaultTuiAgent: settings.defaultTuiAgent,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
visibleTaskProviders: Array.isArray(settings.visibleTaskProviders)
|
||||
? settings.visibleTaskProviders.filter(
|
||||
(value): value is 'github' | 'gitlab' | 'linear' =>
|
||||
value === 'github' || value === 'gitlab' || value === 'linear'
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationTrustedHooks') {
|
||||
MobileWebCreationTrustedHooksPayloadSchema.parse(args.payload)
|
||||
return pageTrust(await operations.readTrustedHooks(), args.authority)
|
||||
}
|
||||
if (
|
||||
args.operation === 'creationGitLabAvailability' ||
|
||||
args.operation === 'creationLinearAvailability'
|
||||
) {
|
||||
MobileWebCreationAvailabilityPayloadSchema.parse(args.payload)
|
||||
const available =
|
||||
args.operation === 'creationGitLabAvailability'
|
||||
? await operations.isGitLabCliInstalled()
|
||||
: await operations.isLinearConnected()
|
||||
return MobileWebCreationAvailabilityResultSchema.parse({ available })
|
||||
}
|
||||
if (args.operation === 'creationRuntimeCapabilities') {
|
||||
MobileWebCreationRuntimeCapabilitiesPayloadSchema.parse(args.payload)
|
||||
const capabilities = await operations.readRuntimeCapabilities()
|
||||
return MobileWebCreationRuntimeCapabilitiesResultSchema.parse({
|
||||
...capabilities,
|
||||
idempotentWorktreeCreateSupported: capabilities.worktreeCreateIdempotency !== false
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationSparsePresets') {
|
||||
const payload = MobileWebCreationRepoPayloadSchema.parse(args.payload)
|
||||
return MobileWebCreationSparsePresetsResultSchema.parse({
|
||||
presets: (await operations.listSparsePresets(args.authority.hostRepoId(payload.repoId))).map(
|
||||
(preset) => ({ ...preset, repoId: payload.repoId })
|
||||
)
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationSaveSparsePreset') {
|
||||
const payload = MobileWebCreationSparsePresetSavePayloadSchema.parse(args.payload)
|
||||
return MobileWebCreationSparsePresetSaveResultSchema.parse({
|
||||
preset: {
|
||||
...(await operations.saveSparsePreset(args.authority.hostRepoId(payload.repoId), {
|
||||
...(payload.id ? { id: payload.id } : {}),
|
||||
name: payload.name,
|
||||
directories: payload.directories
|
||||
})),
|
||||
repoId: payload.repoId
|
||||
}
|
||||
})
|
||||
}
|
||||
return executeRepoCreationRead(args, operations)
|
||||
}
|
||||
|
||||
function pageExecutionHostLabel(
|
||||
executionHostId: ReturnType<typeof getRepoExecutionHostId>
|
||||
): string {
|
||||
const host = parseExecutionHostId(executionHostId)
|
||||
return host?.kind === 'local' ? getExecutionHostLabel(executionHostId) : 'Host'
|
||||
}
|
||||
|
||||
async function executeRepoCreationRead(
|
||||
args: {
|
||||
operation: string
|
||||
payload: unknown
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
},
|
||||
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
|
||||
): Promise<unknown> {
|
||||
if (args.operation === 'creationDetectAgents') {
|
||||
const payload = MobileWebCreationAgentDetectionPayloadSchema.parse(args.payload)
|
||||
const connectionId = payload.repoId ? args.authority.hostConnectionId(payload.repoId) : null
|
||||
return MobileWebCreationAgentDetectionResultSchema.parse({
|
||||
agentIds: await operations.detectAgents(connectionId)
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationPersistTrust') {
|
||||
const payload = MobileWebCreationPersistTrustPayloadSchema.parse(args.payload)
|
||||
const hostRepoId = args.authority.hostRepoId(payload.repoId)
|
||||
const next = await operations.persistSetupTrust({
|
||||
trust: hostTrust(payload.trust, args.authority),
|
||||
repoId: hostRepoId,
|
||||
contentHash: payload.contentHash,
|
||||
alwaysTrust: payload.alwaysTrust
|
||||
})
|
||||
return pageTrust(next, args.authority)
|
||||
}
|
||||
const payload = MobileWebCreationRepoPayloadSchema.parse(args.payload)
|
||||
const hostRepoId = args.authority.hostRepoId(payload.repoId)
|
||||
if (args.operation === 'creationRetiredNames') {
|
||||
return MobileWebCreationRetiredNamesResultSchema.parse(
|
||||
await operations.readRetiredWorktreeNames(hostRepoId)
|
||||
)
|
||||
}
|
||||
if (args.operation === 'creationSshState' || args.operation === 'creationSshConnect') {
|
||||
const connectionId = args.authority.hostConnectionId(payload.repoId)
|
||||
const state =
|
||||
args.operation === 'creationSshConnect'
|
||||
? await operations.connectSsh(connectionId)
|
||||
: await operations.readSshState(connectionId)
|
||||
return MobileWebCreationSshStateResultSchema.parse({
|
||||
targetId: payload.repoId,
|
||||
status: state.status,
|
||||
error: state.error ? 'SSH connection failed.' : null,
|
||||
reconnectAttempt: state.reconnectAttempt,
|
||||
supportsFolderDownload: state.supportsFolderDownload,
|
||||
remotePlatform: state.remotePlatform
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationRepoHooks') {
|
||||
const hooks = await operations.readRepoHooks(hostRepoId)
|
||||
return MobileWebCreationRepoHooksResultSchema.parse({
|
||||
...hooks,
|
||||
source: hooks.source ? 'orca.yaml' : null
|
||||
})
|
||||
}
|
||||
throw new MobileWebBrokerError('unsupported_capability')
|
||||
}
|
||||
|
||||
function pageTrust(
|
||||
trust: PersistedTrustedOrcaHooks,
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
): unknown {
|
||||
const pageEntries: PersistedTrustedOrcaHooks = {}
|
||||
for (const [hostRepoId, entry] of Object.entries(trust)) {
|
||||
try {
|
||||
pageEntries[authority.pageRepoId(hostRepoId)] = entry
|
||||
} catch {
|
||||
// Why: stale trust for a removed repo is irrelevant to the current page authority.
|
||||
}
|
||||
}
|
||||
return MobileWebCreationTrustedHooksResultSchema.parse(pageEntries)
|
||||
}
|
||||
|
||||
function hostTrust(
|
||||
trust: PersistedTrustedOrcaHooks,
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
): PersistedTrustedOrcaHooks {
|
||||
return Object.fromEntries(
|
||||
Object.entries(trust).map(([pageRepoId, entry]) => [authority.hostRepoId(pageRepoId), entry])
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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'
|
||||
|
||||
describe('mobile web workspace creation round trip', () => {
|
||||
it('carries page requests through schemas and resolves host authority only in native', 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',
|
||||
connectionId: 'ssh-private-id'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return { ok: true, result: { worktree: { id: '/host/worktree-secret' } } }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
})
|
||||
const hostClient = { sendRequest } as unknown as RpcClient
|
||||
let requestIndex = 0
|
||||
const { client: pageClient, shellMessages } = createMobileWebBridgeRoundtripFixture({
|
||||
grants: [...MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS],
|
||||
rpcClient: hostClient,
|
||||
createRequestId: () => String.fromCharCode(65 + requestIndex++).repeat(22),
|
||||
randomBytes: (length) => new Uint8Array(length).fill(5),
|
||||
navigationAuthority: {
|
||||
route: vi.fn(),
|
||||
reconnect: vi.fn(),
|
||||
removeHost: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const repositories = await pageClient.workspaceCreation.repositories()
|
||||
const result = await pageClient.workspaceCreationCreate.createBlank({
|
||||
repoId: repositories.repositories[0]!.id,
|
||||
baseName: 'mobile-workspace',
|
||||
nameWasGenerated: false,
|
||||
agentChoice: 'codex',
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
|
||||
expect(repositories.repositories).toEqual([
|
||||
{
|
||||
id: expect.stringMatching(/^repo_/),
|
||||
displayName: 'Orca',
|
||||
path: '/Users/private/orca',
|
||||
connectionId: expect.stringMatching(/^repo_/),
|
||||
executionHostId: expect.stringMatching(/^ssh:executionHost_/),
|
||||
executionHostLabel: 'Host',
|
||||
projectId: expect.stringMatching(/^project_/)
|
||||
}
|
||||
])
|
||||
expect(result).toEqual({
|
||||
workspaceId: expect.stringMatching(/^workspace_/),
|
||||
name: 'mobile-workspace'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:/host/repo-secret',
|
||||
createdWithAgent: 'codex',
|
||||
startupAgent: 'codex'
|
||||
}),
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
const createParams = sendRequest.mock.calls.find(
|
||||
([method]) => method === 'worktree.create'
|
||||
)?.[1]
|
||||
expect(createParams).not.toHaveProperty('startupCommand')
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { executeMobileWebWorkspaceCreationSourceOperation } from './mobile-web-workspace-creation-source-operations'
|
||||
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
describe('mobile web workspace creation sources', () => {
|
||||
it('uses opaque repo authority and strips provider credentials and extra fields', async () => {
|
||||
const authority = workspaceAuthority()
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
const pageRepoId = authority.pageRepoId('host-repo-secret')
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
id: 'durable-provider-id',
|
||||
type: 'pr',
|
||||
number: 7,
|
||||
title: 'Secure item',
|
||||
state: 'open',
|
||||
url: 'https://token:secret@github.example.com/acme/orca/pull/7?access=secret#private',
|
||||
labels: ['mobile'],
|
||||
updatedAt: '2026-07-23T00:00:00Z',
|
||||
author: 'octo',
|
||||
headSha: 'host-only-sha',
|
||||
repoId: 'host-repo-secret'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationSourceOperation({
|
||||
operation: 'creationSearchGitHub',
|
||||
payload: { repoId: pageRepoId, query: 'mobile' },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('github.listWorkItems', {
|
||||
repo: 'id:host-repo-secret',
|
||||
limit: 36,
|
||||
query: 'mobile'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: `github:${pageRepoId}:pr:7`,
|
||||
repoId: pageRepoId,
|
||||
url: 'https://github.example.com/acme/orca/pull/7'
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(
|
||||
/token|secret|durable-provider-id|host-only-sha|host-repo-secret/
|
||||
)
|
||||
})
|
||||
|
||||
it('removes fork remote URLs from hosted base presentations', async () => {
|
||||
const authority = workspaceAuthority()
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
const pageRepoId = authority.pageRepoId('host-repo-secret')
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
baseBranch: 'refs/pull/7/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: {
|
||||
remoteName: 'contributor',
|
||||
branchName: 'feature',
|
||||
remoteUrl: 'https://token:secret@example.com/contributor/orca.git'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await executeMobileWebWorkspaceCreationSourceOperation({
|
||||
operation: 'creationResolvePrBase',
|
||||
payload: { repoId: pageRepoId, prNumber: 7 },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'refs/pull/7/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
pushTarget: { remoteName: 'contributor', branchName: 'feature' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('remoteUrl')
|
||||
expect(JSON.stringify(result)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('maps the SSH GitHub remote requirement without exposing host errors', async () => {
|
||||
const authority = workspaceAuthority()
|
||||
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
|
||||
const pageRepoId = authority.pageRepoId('host-repo-secret')
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: 'GitHub work items require a GitHub remote for SSH repositories: secret-host'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
executeMobileWebWorkspaceCreationSourceOperation({
|
||||
operation: 'creationSearchGitHub',
|
||||
payload: { repoId: pageRepoId, query: 'mobile' },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
authority
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
function workspaceAuthority(): MobileWebWorkspaceAuthority {
|
||||
return new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(9))
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import {
|
||||
MobileWebCreationBranchSearchResultSchema,
|
||||
MobileWebCreationGitHubLookupPayloadSchema,
|
||||
MobileWebCreationGitHubLookupResultSchema,
|
||||
MobileWebCreationGitHubRepoLookupPayloadSchema,
|
||||
MobileWebCreationGitHubSearchResultSchema,
|
||||
MobileWebCreationGitLabLookupPayloadSchema,
|
||||
MobileWebCreationGitLabLookupResultSchema,
|
||||
MobileWebCreationGitLabSearchPayloadSchema,
|
||||
MobileWebCreationGitLabSearchResultSchema,
|
||||
MobileWebCreationHostedBaseResultSchema,
|
||||
MobileWebCreationLinearSearchPayloadSchema,
|
||||
MobileWebCreationLinearSearchResultSchema,
|
||||
MobileWebCreationMrBasePayloadSchema,
|
||||
MobileWebCreationPrBasePayloadSchema,
|
||||
MobileWebCreationRepoQueryPayloadSchema,
|
||||
MobileWebCreationRepoSlugResultSchema
|
||||
} from '../../../src/shared/mobile-web/workspace-creation-source-contract'
|
||||
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
|
||||
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
|
||||
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isGitHubWorkItemsSshRemoteRequiredError } from '../tasks/mobile-work-items'
|
||||
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
export async function executeMobileWebWorkspaceCreationSourceOperation(args: {
|
||||
operation: string
|
||||
payload: unknown
|
||||
client: RpcClient
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
}): Promise<unknown> {
|
||||
const operations = nativeHostWorkspaceCreationOperations(args.client)
|
||||
if (args.operation === 'creationSearchLinear') {
|
||||
const payload = MobileWebCreationLinearSearchPayloadSchema.parse(args.payload)
|
||||
const issues = await operations.searchLinearIssues(payload.query, payload.linearWorkspaceId)
|
||||
return MobileWebCreationLinearSearchResultSchema.parse({
|
||||
issues: issues.map(presentLinearIssue)
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationSearchGitLab') {
|
||||
const payload = MobileWebCreationGitLabSearchPayloadSchema.parse(args.payload)
|
||||
const hostRepoId = args.authority.hostRepoId(payload.repoId)
|
||||
const items = await operations.searchGitLabItems(hostRepoId, payload.query, payload.state)
|
||||
return MobileWebCreationGitLabSearchResultSchema.parse({
|
||||
items: items.map((item) => presentGitLabItem(item, payload.repoId))
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationSearchGitHub' || args.operation === 'creationSearchBranches') {
|
||||
const payload = MobileWebCreationRepoQueryPayloadSchema.parse(args.payload)
|
||||
const hostRepoId = args.authority.hostRepoId(payload.repoId)
|
||||
if (args.operation === 'creationSearchGitHub') {
|
||||
const items = await searchGitHubItems(operations, hostRepoId, payload.query)
|
||||
return MobileWebCreationGitHubSearchResultSchema.parse({
|
||||
items: items.map((item) => presentGitHubItem(item, payload.repoId))
|
||||
})
|
||||
}
|
||||
return MobileWebCreationBranchSearchResultSchema.parse({
|
||||
branches: await operations.searchBranches(hostRepoId, payload.query)
|
||||
})
|
||||
}
|
||||
return executeCreationLookupOrBase(args, operations)
|
||||
}
|
||||
|
||||
async function searchGitHubItems(
|
||||
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>,
|
||||
repoId: string,
|
||||
query: string
|
||||
): ReturnType<typeof operations.searchGitHubItems> {
|
||||
try {
|
||||
return await operations.searchGitHubItems(repoId, query)
|
||||
} catch (error) {
|
||||
if (isGitHubWorkItemsSshRemoteRequiredError(error)) {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCreationLookupOrBase(
|
||||
args: {
|
||||
operation: string
|
||||
payload: unknown
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
},
|
||||
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
|
||||
): Promise<unknown> {
|
||||
if (args.operation === 'creationResolveRepoSlug') {
|
||||
const payload = MobileWebCreationRepoQueryPayloadSchema.pick({ repoId: true }).parse(
|
||||
args.payload
|
||||
)
|
||||
return MobileWebCreationRepoSlugResultSchema.parse(
|
||||
await operations.resolveGitHubRepoSlug(args.authority.hostRepoId(payload.repoId))
|
||||
)
|
||||
}
|
||||
if (args.operation === 'creationLookupGitHub') {
|
||||
const payload = MobileWebCreationGitHubLookupPayloadSchema.parse(args.payload)
|
||||
const item = await operations.lookupGitHubItem(
|
||||
args.authority.hostRepoId(payload.repoId),
|
||||
payload.number
|
||||
)
|
||||
return MobileWebCreationGitHubLookupResultSchema.parse({
|
||||
item: item ? presentGitHubItem(item, payload.repoId) : null
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationLookupGitHubRepo') {
|
||||
const payload = MobileWebCreationGitHubRepoLookupPayloadSchema.parse(args.payload)
|
||||
const item = await operations.lookupGitHubItemByOwnerRepo({
|
||||
...payload,
|
||||
repoId: args.authority.hostRepoId(payload.repoId)
|
||||
})
|
||||
return MobileWebCreationGitHubLookupResultSchema.parse({
|
||||
item: item ? presentGitHubItem(item, payload.repoId) : null
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationLookupGitLab') {
|
||||
const payload = MobileWebCreationGitLabLookupPayloadSchema.parse(args.payload)
|
||||
const item = await operations.lookupGitLabItemByPath({
|
||||
...payload,
|
||||
repoId: args.authority.hostRepoId(payload.repoId)
|
||||
})
|
||||
return MobileWebCreationGitLabLookupResultSchema.parse({
|
||||
item: item ? presentGitLabItem(item, payload.repoId) : null
|
||||
})
|
||||
}
|
||||
if (args.operation === 'creationResolvePrBase') {
|
||||
const payload = MobileWebCreationPrBasePayloadSchema.parse(args.payload)
|
||||
return presentHostedBase(
|
||||
await operations.resolvePrBase({
|
||||
...payload,
|
||||
repoId: args.authority.hostRepoId(payload.repoId)
|
||||
})
|
||||
)
|
||||
}
|
||||
if (args.operation === 'creationResolveMrBase') {
|
||||
const payload = MobileWebCreationMrBasePayloadSchema.parse(args.payload)
|
||||
return presentHostedBase(
|
||||
await operations.resolveMrBase({
|
||||
...payload,
|
||||
repoId: args.authority.hostRepoId(payload.repoId)
|
||||
})
|
||||
)
|
||||
}
|
||||
throw new MobileWebBrokerError('unsupported_capability')
|
||||
}
|
||||
|
||||
function presentGitHubItem(item: GitHubWorkItem, pageRepoId: string): unknown {
|
||||
return {
|
||||
id: `github:${pageRepoId}:${item.type}:${item.number}`,
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
url: sanitizedProviderUrl(item.url),
|
||||
labels: item.labels,
|
||||
updatedAt: item.updatedAt,
|
||||
author: item.author,
|
||||
branchName: item.branchName,
|
||||
baseRefName: item.baseRefName,
|
||||
isCrossRepository: item.isCrossRepository,
|
||||
repoId: pageRepoId
|
||||
}
|
||||
}
|
||||
|
||||
function presentGitLabItem(item: GitLabWorkItem, pageRepoId: string): unknown {
|
||||
return {
|
||||
id: `gitlab:${pageRepoId}:${item.type}:${item.number}`,
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
url: sanitizedProviderUrl(item.url),
|
||||
labels: item.labels,
|
||||
updatedAt: item.updatedAt,
|
||||
author: item.author,
|
||||
branchName: item.branchName,
|
||||
baseRefName: item.baseRefName,
|
||||
isCrossRepository: item.isCrossRepository,
|
||||
repoId: pageRepoId
|
||||
}
|
||||
}
|
||||
|
||||
function presentLinearIssue(issue: LinearIssue): unknown {
|
||||
return {
|
||||
id: `linear:${issue.identifier}`,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
branchName: issue.branchName,
|
||||
url: sanitizedProviderUrl(issue.url),
|
||||
state: issue.state,
|
||||
team: { id: `linear-team:${issue.team.key}`, name: issue.team.name, key: issue.team.key },
|
||||
labels: issue.labels,
|
||||
labelIds: issue.labels.map((label) => `linear-label:${label}`),
|
||||
priority: issue.priority,
|
||||
updatedAt: issue.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function presentHostedBase(result: {
|
||||
baseBranch: string
|
||||
compareBaseRef?: string
|
||||
pushTarget?: { remoteName: string; branchName: string }
|
||||
branchNameOverride?: string
|
||||
maintainerCanModify?: boolean
|
||||
}): unknown {
|
||||
return MobileWebCreationHostedBaseResultSchema.parse({
|
||||
baseBranch: result.baseBranch,
|
||||
compareBaseRef: result.compareBaseRef,
|
||||
pushTarget: result.pushTarget
|
||||
? {
|
||||
remoteName: result.pushTarget.remoteName,
|
||||
branchName: result.pushTarget.branchName
|
||||
}
|
||||
: undefined,
|
||||
branchNameOverride: result.branchNameOverride,
|
||||
maintainerCanModify: result.maintainerCanModify
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizedProviderUrl(value: string): string {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
}
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import { executeMobileWebWorkspaceCreationReadOperation } from './mobile-web-workspace-creation-read-operations'
|
||||
import { executeMobileWebWorkspaceCreationSourceOperation } from './mobile-web-workspace-creation-source-operations'
|
||||
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
import type { MobileWebWorkspaceSnapshotPager } from './mobile-web-workspace-snapshot-pager'
|
||||
@@ -22,15 +20,5 @@ export async function executeMobileWebWorkspaceOperation(args: {
|
||||
if (args.operation.startsWith('creationCreate')) {
|
||||
return executeMobileWebWorkspaceCreationCreateOperation(args)
|
||||
}
|
||||
if (
|
||||
args.operation.startsWith('creationSearch') ||
|
||||
args.operation.startsWith('creationLookup') ||
|
||||
args.operation.startsWith('creationResolve')
|
||||
) {
|
||||
return executeMobileWebWorkspaceCreationSourceOperation(args)
|
||||
}
|
||||
if (args.operation.startsWith('creation')) {
|
||||
return executeMobileWebWorkspaceCreationReadOperation(args)
|
||||
}
|
||||
throw new MobileWebBrokerError('unsupported_capability')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { GitHubPrStartPoint } from '../../../src/shared/worktree/types'
|
||||
|
||||
@@ -15,7 +15,7 @@ type HostedBaseResult = ComposerHostedBase | { error: string }
|
||||
// select-time resolution. The runtime returns a soft { error } payload rather
|
||||
// than an RPC error for provider failures.
|
||||
export type ResolveComposerPrBaseArgs = {
|
||||
client: RpcClient
|
||||
client: RpcRequestSender
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
@@ -50,7 +50,7 @@ export async function resolveComposerPrBase(
|
||||
|
||||
// Resolves a GitLab MR's base via worktree.resolveMrBase.
|
||||
export type ResolveComposerMrBaseArgs = {
|
||||
client: RpcClient
|
||||
client: RpcRequestSender
|
||||
repoId: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
|
||||
export type SetupHookTrust = {
|
||||
contentHash: string
|
||||
@@ -38,7 +38,7 @@ export function trustedOrcaHooksWithSetupApproval(args: {
|
||||
}
|
||||
|
||||
export async function persistSetupHookTrustApproval(args: {
|
||||
client: RpcClient
|
||||
client: RpcRequestSender
|
||||
trust: PersistedTrustedOrcaHooks
|
||||
repoId: string
|
||||
contentHash: string
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
|
||||
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
|
||||
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
|
||||
import type { BaseRefSearchResult } from '../../../src/shared/repo-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
|
||||
import { PER_REPO_FETCH_LIMIT } from './mobile-work-items'
|
||||
@@ -22,7 +22,7 @@ export function scopeGitHubQuery(query: string): string {
|
||||
}
|
||||
|
||||
export async function searchGitHubItems(
|
||||
client: RpcClient,
|
||||
client: RpcRequestSender,
|
||||
repoId: string,
|
||||
query: string
|
||||
): Promise<GitHubWorkItem[]> {
|
||||
@@ -41,7 +41,7 @@ export async function searchGitHubItems(
|
||||
}
|
||||
|
||||
export async function searchGitLabItems(
|
||||
client: RpcClient,
|
||||
client: RpcRequestSender,
|
||||
repoId: string,
|
||||
query: string,
|
||||
state: MrStateFilter
|
||||
@@ -67,7 +67,7 @@ export async function searchGitLabItems(
|
||||
}
|
||||
|
||||
export async function searchLinearIssues(
|
||||
client: RpcClient,
|
||||
client: RpcRequestSender,
|
||||
query: string,
|
||||
linearWorkspaceId: string | null | undefined
|
||||
): Promise<LinearIssue[]> {
|
||||
@@ -94,7 +94,7 @@ export async function searchLinearIssues(
|
||||
}
|
||||
|
||||
export async function searchBranches(
|
||||
client: RpcClient,
|
||||
client: RpcRequestSender,
|
||||
repoId: string,
|
||||
query: string
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
|
||||
@@ -6,12 +6,10 @@ describe('web host task preference operations', () => {
|
||||
it('uses strict task updates and the existing opaque trust operation', async () => {
|
||||
const updateResume = vi.fn().mockResolvedValue(null)
|
||||
const updateSettings = vi.fn().mockResolvedValue(null)
|
||||
const persistTrust = vi.fn().mockResolvedValue({
|
||||
'repo-page-1': { all: { approvedAt: 10 } }
|
||||
})
|
||||
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: {} })
|
||||
const operations = webHostTaskPreferenceOperations({
|
||||
task: { updateResume, updateSettings },
|
||||
workspaceCreation: { persistTrust }
|
||||
hostRpcSender: { sendRequest }
|
||||
} as unknown as MobileWebBridgeClient)
|
||||
|
||||
await operations.updateResume({ githubMode: 'project' })
|
||||
@@ -19,23 +17,21 @@ describe('web host task preference operations', () => {
|
||||
await expect(
|
||||
operations.persistSetupTrust({
|
||||
trust: {},
|
||||
repoId: 'repo-page-1',
|
||||
repoId: 'repo-1',
|
||||
contentHash: 'f'.repeat(64),
|
||||
alwaysTrust: true
|
||||
alwaysTrust: true,
|
||||
approvedAt: 10
|
||||
})
|
||||
).resolves.toEqual({
|
||||
'repo-page-1': { all: { approvedAt: 10 } }
|
||||
'repo-1': { all: { approvedAt: 10 } }
|
||||
})
|
||||
|
||||
expect(updateResume).toHaveBeenCalledWith({
|
||||
taskResumeState: { githubMode: 'project' }
|
||||
})
|
||||
expect(updateSettings).toHaveBeenCalledWith({ defaultTaskSource: 'linear' })
|
||||
expect(persistTrust).toHaveBeenCalledWith({
|
||||
trust: {},
|
||||
repoId: 'repo-page-1',
|
||||
contentHash: 'f'.repeat(64),
|
||||
alwaysTrust: true
|
||||
expect(sendRequest).toHaveBeenCalledWith('ui.set', {
|
||||
trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 10 } } }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import { persistSetupHookTrustApproval } from './setup-hook-trust'
|
||||
import type { HostTaskPreferenceOperations } from './host-task-preference-operations'
|
||||
|
||||
export function webHostTaskPreferenceOperations(
|
||||
@@ -11,6 +12,7 @@ export function webHostTaskPreferenceOperations(
|
||||
async updateSettings(settings) {
|
||||
await client.task.updateSettings(settings)
|
||||
},
|
||||
persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args)
|
||||
persistSetupTrust: (args) =>
|
||||
persistSetupHookTrustApproval({ client: client.hostRpcSender, ...args })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
@@ -33,7 +33,7 @@ const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
|
||||
// Why: status.get is safe to replay and must settle before create, independently
|
||||
// of slower provider probes, so ambiguous cutover retries are gated correctly.
|
||||
export async function readNewWorktreeRuntimeCapabilities(
|
||||
client: RpcClient
|
||||
client: RpcRequestSender
|
||||
): Promise<NewWorktreeRuntimeCapabilities> {
|
||||
for (let migrationRetry = 0; ; migrationRetry += 1) {
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,10 @@ export type RpcClient = {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** The one method a caller needs to reach the desktop. The hosted page satisfies it over the
|
||||
* bridge, so request code written against the socket runs unchanged inside the webview. */
|
||||
export type RpcRequestSender = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
export type ConnectOptions = {
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
onLog?: ConnectionLogSink
|
||||
|
||||
@@ -1,49 +1,14 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
|
||||
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
|
||||
import { persistSetupHookTrustApproval } from '../tasks/setup-hook-trust'
|
||||
import type { HostWorkspaceCreationOperations } from './host-workspace-creation-operations'
|
||||
import { nativeHostWorkspaceCreationReadOperations } from './native-host-workspace-creation-read-operations'
|
||||
import { nativeHostWorkspaceCreationSourceOperations } from './native-host-workspace-creation-source-operations'
|
||||
import { rpcWorkspaceCreationOperations } from './rpc-workspace-creation-operations'
|
||||
|
||||
export function nativeHostWorkspaceCreationOperations(
|
||||
client: RpcClient
|
||||
): HostWorkspaceCreationOperations {
|
||||
return {
|
||||
...nativeHostWorkspaceCreationReadOperations(client),
|
||||
...nativeHostWorkspaceCreationSourceOperations(client),
|
||||
async listSparsePresets(repoId) {
|
||||
const response = await client.sendRequest('repo.sparsePresets', { repo: `id:${repoId}` })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return (
|
||||
(
|
||||
response.result as {
|
||||
presets?: Awaited<ReturnType<HostWorkspaceCreationOperations['listSparsePresets']>>
|
||||
}
|
||||
).presets ?? []
|
||||
)
|
||||
},
|
||||
async saveSparsePreset(repoId, payload) {
|
||||
const response = await client.sendRequest('repo.saveSparsePreset', {
|
||||
repo: `id:${repoId}`,
|
||||
...payload
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const preset = (
|
||||
response.result as {
|
||||
preset?: Awaited<ReturnType<HostWorkspaceCreationOperations['saveSparsePreset']>>
|
||||
}
|
||||
).preset
|
||||
if (!preset) {
|
||||
throw new Error('Failed to save sparse preset.')
|
||||
}
|
||||
return preset
|
||||
},
|
||||
persistSetupTrust: (args) => persistSetupHookTrustApproval({ client, ...args }),
|
||||
...rpcWorkspaceCreationOperations(client),
|
||||
async createBlankWorkspace(args) {
|
||||
return createBlankWorkspace({
|
||||
client,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
|
||||
import { readRetiredNameRegistryForRepo } from '../../../src/shared/worktree/retired-name-cache'
|
||||
@@ -26,7 +26,9 @@ type ReadOperations = Pick<
|
||||
| 'readRuntimeCapabilities'
|
||||
>
|
||||
|
||||
export function nativeHostWorkspaceCreationReadOperations(client: RpcClient): ReadOperations {
|
||||
export function nativeHostWorkspaceCreationReadOperations(
|
||||
client: RpcRequestSender
|
||||
): ReadOperations {
|
||||
return {
|
||||
async listRepositories() {
|
||||
const result = await successfulResult<{ repos: NewWorkspaceRepository[] }>(
|
||||
@@ -95,7 +97,7 @@ export function nativeHostWorkspaceCreationReadOperations(client: RpcClient): Re
|
||||
}
|
||||
|
||||
async function successfulResult<T>(
|
||||
responsePromise: ReturnType<RpcClient['sendRequest']>
|
||||
responsePromise: ReturnType<RpcRequestSender['sendRequest']>
|
||||
): Promise<T> {
|
||||
const response = await responsePromise
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RepoSlug } from '../../../src/shared/new-workspace/github-links'
|
||||
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
|
||||
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { resolveComposerMrBase, resolveComposerPrBase } from '../tasks/composer-source-base-resolve'
|
||||
import {
|
||||
@@ -26,7 +26,9 @@ type SourceOperations = Pick<
|
||||
| 'resolveMrBase'
|
||||
>
|
||||
|
||||
export function nativeHostWorkspaceCreationSourceOperations(client: RpcClient): SourceOperations {
|
||||
export function nativeHostWorkspaceCreationSourceOperations(
|
||||
client: RpcRequestSender
|
||||
): SourceOperations {
|
||||
return {
|
||||
searchGitHubItems: (repoId, query) => searchGitHubItems(client, repoId, query),
|
||||
searchGitLabItems: (repoId, query, state) => searchGitLabItems(client, repoId, query, state),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
import { persistSetupHookTrustApproval } from '../tasks/setup-hook-trust'
|
||||
import type { HostWorkspaceCreationOperations } from './host-workspace-creation-operations'
|
||||
import { nativeHostWorkspaceCreationReadOperations } from './native-host-workspace-creation-read-operations'
|
||||
import { nativeHostWorkspaceCreationSourceOperations } from './native-host-workspace-creation-source-operations'
|
||||
|
||||
export type RpcWorkspaceCreationOperations = Omit<
|
||||
HostWorkspaceCreationOperations,
|
||||
'createBlankWorkspace' | 'createWorkspaceFromSource'
|
||||
>
|
||||
|
||||
/** Every workspace-creation call that is a plain desktop request. The native app passes its socket
|
||||
* and the hosted page passes a bridge-backed sender, so neither side owns a second copy. Creation
|
||||
* itself is excluded: it needs connection state for its retry, which a sender cannot report. */
|
||||
export function rpcWorkspaceCreationOperations(
|
||||
client: RpcRequestSender
|
||||
): RpcWorkspaceCreationOperations {
|
||||
return {
|
||||
...nativeHostWorkspaceCreationReadOperations(client),
|
||||
...nativeHostWorkspaceCreationSourceOperations(client),
|
||||
async listSparsePresets(repoId) {
|
||||
const response = await client.sendRequest('repo.sparsePresets', { repo: `id:${repoId}` })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return (
|
||||
(
|
||||
response.result as {
|
||||
presets?: Awaited<ReturnType<HostWorkspaceCreationOperations['listSparsePresets']>>
|
||||
}
|
||||
).presets ?? []
|
||||
)
|
||||
},
|
||||
async saveSparsePreset(repoId, payload) {
|
||||
const response = await client.sendRequest('repo.saveSparsePreset', {
|
||||
repo: `id:${repoId}`,
|
||||
...payload
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const preset = (
|
||||
response.result as {
|
||||
preset?: Awaited<ReturnType<HostWorkspaceCreationOperations['saveSparsePreset']>>
|
||||
}
|
||||
).preset
|
||||
if (!preset) {
|
||||
throw new Error('Failed to save sparse preset.')
|
||||
}
|
||||
return preset
|
||||
},
|
||||
persistSetupTrust: (args) => persistSetupHookTrustApproval({ client, ...args })
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-w
|
||||
import { webHostWorkspaceCreationOperations } from './web-host-workspace-creation-operations'
|
||||
|
||||
describe('web host workspace creation operations', () => {
|
||||
it('rebuilds bounded mobile view models with opaque hosted authority', async () => {
|
||||
it('forwards the catalog reads the native app makes, unprojected', async () => {
|
||||
const client = bridgeClient()
|
||||
const operations = webHostWorkspaceCreationOperations(
|
||||
client as unknown as MobileWebBridgeClient
|
||||
@@ -12,21 +12,21 @@ describe('web host workspace creation operations', () => {
|
||||
|
||||
await expect(operations.listRepositories()).resolves.toEqual([
|
||||
{
|
||||
id: 'repo-page-1',
|
||||
id: 'repo-1',
|
||||
displayName: 'Orca',
|
||||
connectionId: 'repo-page-1',
|
||||
executionHostId: 'ssh:executionHost-page-1',
|
||||
executionHostLabel: 'Host',
|
||||
projectId: 'project-page-1',
|
||||
connectionId: 'connection-1',
|
||||
executionHostId: 'ssh:host-1',
|
||||
kind: 'git',
|
||||
path: '/workspace/orca'
|
||||
}
|
||||
])
|
||||
await expect(operations.readRuntimeSettings()).resolves.toEqual({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude'],
|
||||
disabledTuiAgents: ['claude', 'unknown-agent'],
|
||||
visibleTaskProviders: ['github']
|
||||
})
|
||||
expect(client.hostRpcSender.sendRequest).toHaveBeenCalledWith('repo.list')
|
||||
expect(client.hostRpcSender.sendRequest).toHaveBeenCalledWith('settings.get')
|
||||
})
|
||||
|
||||
it('sends only a named agent choice and strips page-visible fork remote URLs', async () => {
|
||||
@@ -124,42 +124,56 @@ describe('web host workspace creation operations', () => {
|
||||
|
||||
it('preserves the native SSH GitHub remote state without exposing host details', async () => {
|
||||
const client = bridgeClient()
|
||||
client.workspaceCreationSource.searchGitHub.mockRejectedValue(
|
||||
new MobileWebBridgeClientError('not_found', false)
|
||||
)
|
||||
client.sendRequest.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'not_found', message: 'not_found' }
|
||||
})
|
||||
const operations = webHostWorkspaceCreationOperations(
|
||||
client as unknown as MobileWebBridgeClient
|
||||
)
|
||||
|
||||
await expect(operations.searchGitHubItems('repo-page-1', '')).rejects.toThrow(
|
||||
await expect(operations.searchGitHubItems('repo-1', '')).rejects.toThrow(
|
||||
'GitHub work items require a GitHub remote for SSH repositories'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function bridgeClient() {
|
||||
return {
|
||||
workspaceCreation: {
|
||||
repositories: vi.fn().mockResolvedValue({
|
||||
repositories: [
|
||||
{
|
||||
id: 'repo-page-1',
|
||||
displayName: 'Orca',
|
||||
connectionId: 'repo-page-1',
|
||||
executionHostId: 'ssh:executionHost-page-1',
|
||||
executionHostLabel: 'Host',
|
||||
projectId: 'project-page-1',
|
||||
path: '/workspace/orca',
|
||||
kind: 'git'
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'Orca',
|
||||
connectionId: 'connection-1',
|
||||
executionHostId: 'ssh:host-1',
|
||||
path: '/workspace/orca',
|
||||
kind: 'git'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
settings: {
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude', 'unknown-agent'],
|
||||
visibleTaskProviders: ['github']
|
||||
}
|
||||
]
|
||||
}),
|
||||
settings: vi.fn().mockResolvedValue({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude', 'unknown-agent'],
|
||||
visibleTaskProviders: ['github']
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
})
|
||||
return {
|
||||
sendRequest,
|
||||
hostRpcSender: { sendRequest },
|
||||
workspaceCreationCreate: {
|
||||
createBlank: vi.fn().mockResolvedValue({
|
||||
workspaceId: 'workspace-page-1',
|
||||
@@ -170,9 +184,6 @@ function bridgeClient() {
|
||||
name: 'pr-7',
|
||||
warning: 'Setup completed with a warning.'
|
||||
})
|
||||
},
|
||||
workspaceCreationSource: {
|
||||
searchGitHub: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,41 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-web-bridge-client-error'
|
||||
import type {
|
||||
MobileWebCreationSelection,
|
||||
MobileWebCreationFromSourcePayload
|
||||
} from '../../../src/shared/mobile-web/workspace-creation-create-contract'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../tasks/mobile-work-items'
|
||||
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
|
||||
import type {
|
||||
CreateBlankWorkspaceOperationArgs,
|
||||
CreateWorkspaceFromSourceOperationArgs,
|
||||
HostWorkspaceCreationOperations,
|
||||
NewWorkspaceRuntimeSettings
|
||||
HostWorkspaceCreationOperations
|
||||
} from './host-workspace-creation-operations'
|
||||
import { rpcWorkspaceCreationOperations } from './rpc-workspace-creation-operations'
|
||||
|
||||
export function webHostWorkspaceCreationOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostWorkspaceCreationOperations {
|
||||
// Every read and lookup is the same desktop request the native app makes, forwarded verbatim.
|
||||
const operations = rpcWorkspaceCreationOperations(client.hostRpcSender)
|
||||
return {
|
||||
async listRepositories() {
|
||||
return (await client.workspaceCreation.repositories()).repositories
|
||||
},
|
||||
readRetiredWorktreeNames: (repoId) => client.workspaceCreation.retiredNames({ repoId }),
|
||||
readRuntimeSettings: async () => webRuntimeSettings(await client.workspaceCreation.settings()),
|
||||
readTrustedHooks: () => client.workspaceCreation.trustedHooks(),
|
||||
isGitLabCliInstalled: () => client.workspaceCreation.gitLabAvailable(),
|
||||
isLinearConnected: () => client.workspaceCreation.linearAvailable(),
|
||||
readSshState: (repoId) => client.workspaceCreation.sshState({ repoId }),
|
||||
connectSsh: (repoId) => client.workspaceCreation.sshConnect({ repoId }),
|
||||
detectAgents: (repoId) => client.workspaceCreation.detectAgents({ repoId }),
|
||||
readRepoHooks: (repoId) => client.workspaceCreation.repoHooks({ repoId }),
|
||||
readRuntimeCapabilities: () => client.workspaceCreation.runtimeCapabilities(),
|
||||
listSparsePresets: (repoId) => client.workspaceCreation.sparsePresets({ repoId }),
|
||||
saveSparsePreset: (repoId, payload) =>
|
||||
client.workspaceCreation.saveSparsePreset({ repoId, ...payload }),
|
||||
persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args),
|
||||
...operations,
|
||||
async searchGitHubItems(repoId, query) {
|
||||
try {
|
||||
return await client.workspaceCreationSource.searchGitHub(repoId, query)
|
||||
return await operations.searchGitHubItems(repoId, query)
|
||||
} catch (error) {
|
||||
if (error instanceof MobileWebBridgeClientError && error.code === 'not_found') {
|
||||
// The bridge collapses a host error to its code, so the host's own wording is gone by the
|
||||
// time it reaches here and the SSH-remote guidance has to be restored.
|
||||
if (error instanceof Error && error.message === 'not_found') {
|
||||
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
searchGitLabItems: (repoId, query, state) =>
|
||||
client.workspaceCreationSource.searchGitLab(repoId, query, state),
|
||||
searchLinearIssues: (query, linearWorkspaceId) =>
|
||||
client.workspaceCreationSource.searchLinear(query, linearWorkspaceId),
|
||||
searchBranches: (repoId, query) => client.workspaceCreationSource.searchBranches(repoId, query),
|
||||
resolveGitHubRepoSlug: (repoId) => client.workspaceCreationSource.resolveRepoSlug(repoId),
|
||||
lookupGitHubItem: (repoId, number) =>
|
||||
client.workspaceCreationSource.lookupGitHub(repoId, number),
|
||||
lookupGitHubItemByOwnerRepo: (args) => client.workspaceCreationSource.lookupGitHubRepo(args),
|
||||
lookupGitLabItemByPath: (args) => client.workspaceCreationSource.lookupGitLab(args),
|
||||
resolvePrBase: (args) => client.workspaceCreationSource.resolvePrBase(args),
|
||||
resolveMrBase: (args) => client.workspaceCreationSource.resolveMrBase(args),
|
||||
createBlankWorkspace: (args) => createBlankWorkspace(client, args),
|
||||
createWorkspaceFromSource: (args) => createWorkspaceFromSource(client, args)
|
||||
}
|
||||
}
|
||||
|
||||
function webRuntimeSettings(settings: {
|
||||
defaultTuiAgent?: string | null
|
||||
disabledTuiAgents?: string[]
|
||||
visibleTaskProviders?: ('github' | 'gitlab' | 'linear')[]
|
||||
}): NewWorkspaceRuntimeSettings {
|
||||
const defaultTuiAgent = normalizeWorkspaceAgent(settings.defaultTuiAgent)
|
||||
return {
|
||||
defaultTuiAgent,
|
||||
disabledTuiAgents: settings.disabledTuiAgents?.flatMap((agent) => {
|
||||
const normalized = normalizeWorkspaceAgent(agent)
|
||||
return normalized && normalized !== 'blank' ? [normalized as TuiAgent] : []
|
||||
}),
|
||||
visibleTaskProviders: settings.visibleTaskProviders
|
||||
}
|
||||
}
|
||||
|
||||
async function createBlankWorkspace(
|
||||
client: MobileWebBridgeClient,
|
||||
args: CreateBlankWorkspaceOperationArgs
|
||||
|
||||
@@ -42,6 +42,28 @@ export const MOBILE_WEB_HOST_RPC_METHODS = new Set([
|
||||
'accounts.selectCodex',
|
||||
'accounts.selectCodexForTarget',
|
||||
'accounts.subscribe',
|
||||
'status.get',
|
||||
'settings.get',
|
||||
'linear.status',
|
||||
'linear.listIssues',
|
||||
'linear.searchIssues',
|
||||
'preflight.check',
|
||||
'preflight.detectAgents',
|
||||
'preflight.detectRemoteAgents',
|
||||
'ssh.getState',
|
||||
'ssh.connect',
|
||||
'repo.hooks',
|
||||
'repo.sparsePresets',
|
||||
'repo.saveSparsePreset',
|
||||
'worktree.listRetiredNames',
|
||||
'worktree.resolvePrBase',
|
||||
'worktree.resolveMrBase',
|
||||
'github.repoSlug',
|
||||
'github.workItem',
|
||||
'github.workItemByOwnerRepo',
|
||||
'github.listWorkItems',
|
||||
'gitlab.workItemByPath',
|
||||
'gitlab.listWorkItems',
|
||||
'terminal.getAutoRestoreFit',
|
||||
'terminal.setAutoRestoreFit',
|
||||
'speech.models.list',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MobileWebHostRequestClient } from './mobile-web-host-request-client'
|
||||
import { mobileWebHostRpcSender, type MobileWebHostRpcSender } from './mobile-web-host-rpc-sender'
|
||||
import {
|
||||
subscribeHostSourceControl,
|
||||
type MobileWebSourceControlSubscriptionArgs
|
||||
@@ -56,8 +57,6 @@ import * as terminal from './mobile-web-terminal-request-client'
|
||||
import { mobileWebWorkspaceClientBindings } from './mobile-web-workspace-client-bindings'
|
||||
import { MobileWebWorkspaceRequestClient } from './mobile-web-workspace-request-client'
|
||||
import { MobileWebWorkspaceCreationCreateRequestClient } from './mobile-web-workspace-creation-create-request-client'
|
||||
import { MobileWebWorkspaceCreationRequestClient } from './mobile-web-workspace-creation-request-client'
|
||||
import { MobileWebWorkspaceCreationSourceRequestClient } from './mobile-web-workspace-creation-source-request-client'
|
||||
|
||||
type InitMessage = Extract<MobileWebBridgeShellMessage, { type: 'init' }>
|
||||
type OperationGrant = InitMessage['grants'][number]
|
||||
@@ -67,6 +66,7 @@ export class MobileWebBridgeClient {
|
||||
private readonly grants = new Map<string, OperationGrant>()
|
||||
private readonly requests: MobileWebOneShotRequestClient
|
||||
readonly host: MobileWebHostRequestClient
|
||||
readonly hostRpcSender: MobileWebHostRpcSender
|
||||
readonly fileList!: MobileWebFileRequestClient['list']
|
||||
readonly fileSearch!: MobileWebFileRequestClient['search']
|
||||
readonly fileDirectory!: MobileWebFileRequestClient['directory']
|
||||
@@ -120,8 +120,6 @@ export class MobileWebBridgeClient {
|
||||
readonly workspaceRemove!: MobileWebWorkspaceRequestClient['remove']
|
||||
readonly workspaceSettingsSnapshot!: MobileWebWorkspaceRequestClient['settingsSnapshot']
|
||||
readonly workspaceSettingsUpdate!: MobileWebWorkspaceRequestClient['settingsUpdate']
|
||||
readonly workspaceCreation: MobileWebWorkspaceCreationRequestClient
|
||||
readonly workspaceCreationSource: MobileWebWorkspaceCreationSourceRequestClient
|
||||
readonly workspaceCreationCreate: MobileWebWorkspaceCreationCreateRequestClient
|
||||
readonly navigationRoute!: MobileWebNavigationRequestClient['route']
|
||||
readonly navigationReconnect!: MobileWebNavigationRequestClient['reconnect']
|
||||
@@ -197,14 +195,13 @@ export class MobileWebBridgeClient {
|
||||
this,
|
||||
mobileWebWorkspaceClientBindings(new MobileWebWorkspaceRequestClient(this.requests))
|
||||
)
|
||||
this.workspaceCreation = new MobileWebWorkspaceCreationRequestClient(this.requests)
|
||||
this.workspaceCreationSource = new MobileWebWorkspaceCreationSourceRequestClient(this.requests)
|
||||
this.workspaceCreationCreate = new MobileWebWorkspaceCreationCreateRequestClient(this.requests)
|
||||
const sessionRequests = new MobileWebSessionRequestClient(this.requests)
|
||||
Object.assign(this, mobileWebSessionClientBindings(sessionRequests))
|
||||
this.native = new MobileWebNativeRequestClient(this.requests)
|
||||
this.markdown = new MobileWebMarkdownRequestClient(this.requests)
|
||||
this.host = new MobileWebHostRequestClient(this.requests)
|
||||
this.hostRpcSender = mobileWebHostRpcSender(this.requests)
|
||||
Object.assign(this, terminal.mobileWebTerminalClientBindings(this.requests))
|
||||
Object.assign(this, mobileWebBrowserNavigationClientBindings(this.requests))
|
||||
this.subscriptions = new MobileWebBridgeSubscriptionClient({
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_WEB_HOST_REQUEST_MAX_TIMEOUT_MS } from '../../shared/mobile-web/host-rpc-contract'
|
||||
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
|
||||
import { mobileWebHostRpcSender } from './mobile-web-host-rpc-sender'
|
||||
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
|
||||
|
||||
function fixture(result: unknown, reject = false) {
|
||||
const request = reject ? vi.fn().mockRejectedValue(result) : vi.fn().mockResolvedValue(result)
|
||||
return {
|
||||
request,
|
||||
sender: mobileWebHostRpcSender({ request } as unknown as MobileWebOneShotRequestClient)
|
||||
}
|
||||
}
|
||||
|
||||
describe('host RPC sender', () => {
|
||||
it('answers in the shape desktop request code already expects', async () => {
|
||||
const f = fixture({ repos: [] })
|
||||
|
||||
await expect(f.sender.sendRequest('repo.list')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { repos: [] }
|
||||
})
|
||||
expect(f.request).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'hostRequest',
|
||||
{ method: 'repo.list', params: {} },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('turns a bridge failure into a failed response instead of throwing', async () => {
|
||||
const f = fixture(new MobileWebBridgeClientError('not_found', false), true)
|
||||
|
||||
await expect(f.sender.sendRequest('repo.hooks', { repo: 'id:repo-1' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'not_found' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a long deadline to the shell so an SSH connect is not cut short', async () => {
|
||||
const f = fixture({ state: null })
|
||||
|
||||
await f.sender.sendRequest('ssh.connect', { targetId: 'host-1' }, { timeoutMs: 120_000 })
|
||||
|
||||
expect(f.request.mock.calls[0]![2]).toMatchObject({ timeoutMs: 120_000 })
|
||||
})
|
||||
|
||||
it('clamps a deadline the envelope would reject', async () => {
|
||||
const f = fixture({})
|
||||
|
||||
await f.sender.sendRequest('ssh.connect', {}, { timeoutMs: 10 * 60_000 })
|
||||
|
||||
expect(f.request.mock.calls[0]![2]).toMatchObject({
|
||||
timeoutMs: MOBILE_WEB_HOST_REQUEST_MAX_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
|
||||
import { requestMobileWebHost } from './mobile-web-host-request-client'
|
||||
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
|
||||
|
||||
export type MobileWebHostRpcResponse =
|
||||
| { id: string; ok: true; result: unknown; _meta: { runtimeId: string } }
|
||||
| {
|
||||
id: string
|
||||
ok: false
|
||||
error: { code: string; message: string; data?: unknown }
|
||||
_meta: { runtimeId: string }
|
||||
}
|
||||
|
||||
export type MobileWebHostRpcSendOptions = {
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type MobileWebHostRpcSender = {
|
||||
sendRequest: (
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: MobileWebHostRpcSendOptions
|
||||
) => Promise<MobileWebHostRpcResponse>
|
||||
}
|
||||
|
||||
const META = { runtimeId: 'hosted' }
|
||||
|
||||
function record(params: unknown): Record<string, unknown> {
|
||||
return typeof params === 'object' && params !== null && !Array.isArray(params)
|
||||
? (params as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
/** An `RpcClient.sendRequest` over the generic host lane, so page code written against the desktop
|
||||
* RPC runs unchanged inside the webview. Host-wide by construction: a workspace-scoped caller
|
||||
* passes its handle in `workspaceId`, and the shell rewrites it into the worktree selector. */
|
||||
export function mobileWebHostRpcSender(
|
||||
requests: MobileWebOneShotRequestClient,
|
||||
workspaceId?: string
|
||||
): MobileWebHostRpcSender {
|
||||
return {
|
||||
async sendRequest(method, params, options) {
|
||||
try {
|
||||
const result = await requestMobileWebHost(
|
||||
requests,
|
||||
method,
|
||||
workspaceId,
|
||||
record(params),
|
||||
options
|
||||
)
|
||||
return { id: method, ok: true, result, _meta: META }
|
||||
} catch (error) {
|
||||
const code = error instanceof MobileWebBridgeClientError ? error.code : 'internal'
|
||||
return {
|
||||
id: method,
|
||||
ok: false,
|
||||
error: { code, message: error instanceof Error ? error.message : code },
|
||||
_meta: META
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ const CONTEXT = {
|
||||
const REQUEST_ID = 'R'.repeat(22)
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const OTHER_WORKSPACE_ID = 'workspace-2'
|
||||
const REPO_ID = 'repo-1'
|
||||
const OTHER_REPO_ID = 'repo-2'
|
||||
const TARGET_ID = 'task-target-1'
|
||||
const OTHER_TARGET_ID = 'task-target-2'
|
||||
|
||||
@@ -68,53 +66,6 @@ const CORRELATION_CASES: CorrelationCase[] = [
|
||||
refusalReason: null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'workspace creation SSH target',
|
||||
capability: 'workspace',
|
||||
operation: 'creationSshState',
|
||||
invoke: (client) => client.workspaceCreation.sshState({ repoId: REPO_ID }),
|
||||
result: sshState(OTHER_REPO_ID)
|
||||
},
|
||||
{
|
||||
name: 'workspace creation sparse preset repository',
|
||||
capability: 'workspace',
|
||||
operation: 'creationSparsePresets',
|
||||
invoke: (client) => client.workspaceCreation.sparsePresets({ repoId: REPO_ID }),
|
||||
result: { presets: [sparsePreset(OTHER_REPO_ID)] }
|
||||
},
|
||||
{
|
||||
name: 'saved sparse preset repository',
|
||||
capability: 'workspace',
|
||||
operation: 'creationSaveSparsePreset',
|
||||
invoke: (client) =>
|
||||
client.workspaceCreation.saveSparsePreset({
|
||||
repoId: REPO_ID,
|
||||
id: 'preset-1',
|
||||
name: 'Sources',
|
||||
directories: ['src']
|
||||
}),
|
||||
result: { preset: sparsePreset(OTHER_REPO_ID) }
|
||||
},
|
||||
{
|
||||
name: 'workspace creation search repository',
|
||||
capability: 'workspace',
|
||||
operation: 'creationSearchGitHub',
|
||||
invoke: (client) => client.workspaceCreationSource.searchGitHub(REPO_ID, 'issue'),
|
||||
result: { items: [gitHubCreationItem({ repoId: OTHER_REPO_ID })] }
|
||||
},
|
||||
{
|
||||
name: 'workspace creation lookup number',
|
||||
capability: 'workspace',
|
||||
operation: 'creationLookupGitHubRepo',
|
||||
invoke: (client) =>
|
||||
client.workspaceCreationSource.lookupGitHubRepo({
|
||||
repoId: REPO_ID,
|
||||
slug: { owner: 'orca', repo: 'orca' },
|
||||
number: 42,
|
||||
type: 'issue'
|
||||
}),
|
||||
result: { item: gitHubCreationItem({ number: 43 }) }
|
||||
},
|
||||
{
|
||||
name: 'task project host',
|
||||
capability: 'task',
|
||||
@@ -258,42 +209,6 @@ function sessionSnapshot(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function sshState(targetId: string) {
|
||||
return {
|
||||
targetId,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function sparsePreset(repoId: string) {
|
||||
return {
|
||||
id: 'preset-1',
|
||||
repoId,
|
||||
name: 'Sources',
|
||||
directories: ['src'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function gitHubCreationItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'github:item:42',
|
||||
type: 'issue',
|
||||
number: 42,
|
||||
title: 'Issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/orca/orca/issues/42',
|
||||
labels: [],
|
||||
updatedAt: '2026-07-28T00:00:00Z',
|
||||
author: 'orca',
|
||||
repoId: REPO_ID,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function linearIssue(targetId: string) {
|
||||
return {
|
||||
id: 'linear-issue-1',
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
|
||||
import {
|
||||
MobileWebCreationAgentDetectionPayloadSchema,
|
||||
MobileWebCreationAgentDetectionResultSchema,
|
||||
MobileWebCreationAvailabilityPayloadSchema,
|
||||
MobileWebCreationAvailabilityResultSchema,
|
||||
MobileWebCreationPersistTrustPayloadSchema,
|
||||
MobileWebCreationRepoHooksResultSchema,
|
||||
MobileWebCreationRepoPayloadSchema,
|
||||
MobileWebCreationRepositoriesPayloadSchema,
|
||||
MobileWebCreationRepositoriesResultSchema,
|
||||
MobileWebCreationRetiredNamesResultSchema,
|
||||
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
|
||||
MobileWebCreationRuntimeCapabilitiesResultSchema,
|
||||
MobileWebCreationSettingsPayloadSchema,
|
||||
MobileWebCreationSettingsResultSchema,
|
||||
MobileWebCreationSparsePresetSavePayloadSchema,
|
||||
MobileWebCreationSparsePresetSaveResultSchema,
|
||||
MobileWebCreationSparsePresetsResultSchema,
|
||||
MobileWebCreationSshStateResultSchema,
|
||||
MobileWebCreationTrustedHooksPayloadSchema,
|
||||
MobileWebCreationTrustedHooksResultSchema,
|
||||
type MobileWebCreationAgentDetectionPayload,
|
||||
type MobileWebCreationPersistTrustPayload,
|
||||
type MobileWebCreationRepoHooksResult,
|
||||
type MobileWebCreationRepoPayload,
|
||||
type MobileWebCreationRepositoriesResult,
|
||||
type MobileWebCreationRetiredNamesResult,
|
||||
type MobileWebCreationRuntimeCapabilitiesResult,
|
||||
type MobileWebCreationSettingsResult,
|
||||
type MobileWebCreationSparsePresetSavePayload,
|
||||
type MobileWebCreationSshStateResult,
|
||||
type MobileWebCreationTrustedHooksResult
|
||||
} from '../../shared/mobile-web/workspace-creation-read-contract'
|
||||
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
|
||||
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
|
||||
|
||||
export class MobileWebWorkspaceCreationRequestClient {
|
||||
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
|
||||
|
||||
repositories(): Promise<MobileWebCreationRepositoriesResult> {
|
||||
return this.emptyRequest(
|
||||
'creationRepositories',
|
||||
MobileWebCreationRepositoriesPayloadSchema,
|
||||
MobileWebCreationRepositoriesResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
retiredNames(
|
||||
payload: MobileWebCreationRepoPayload
|
||||
): Promise<MobileWebCreationRetiredNamesResult> {
|
||||
return this.repoRequest(
|
||||
'creationRetiredNames',
|
||||
payload,
|
||||
MobileWebCreationRetiredNamesResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
settings(): Promise<MobileWebCreationSettingsResult> {
|
||||
return this.emptyRequest(
|
||||
'creationSettings',
|
||||
MobileWebCreationSettingsPayloadSchema,
|
||||
MobileWebCreationSettingsResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
trustedHooks(): Promise<MobileWebCreationTrustedHooksResult> {
|
||||
return this.emptyRequest(
|
||||
'creationTrustedHooks',
|
||||
MobileWebCreationTrustedHooksPayloadSchema,
|
||||
MobileWebCreationTrustedHooksResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
gitLabAvailable(): Promise<boolean> {
|
||||
return this.availability('creationGitLabAvailability')
|
||||
}
|
||||
|
||||
linearAvailable(): Promise<boolean> {
|
||||
return this.availability('creationLinearAvailability')
|
||||
}
|
||||
|
||||
sshState(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationSshStateResult> {
|
||||
return this.repoRequest<MobileWebCreationSshStateResult>(
|
||||
'creationSshState',
|
||||
payload,
|
||||
MobileWebCreationSshStateResultSchema
|
||||
).then((result) => matchingRepoTarget(payload, result))
|
||||
}
|
||||
|
||||
sshConnect(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationSshStateResult> {
|
||||
return this.repoRequest<MobileWebCreationSshStateResult>(
|
||||
'creationSshConnect',
|
||||
payload,
|
||||
MobileWebCreationSshStateResultSchema
|
||||
).then((result) => matchingRepoTarget(payload, result))
|
||||
}
|
||||
|
||||
detectAgents(payload: MobileWebCreationAgentDetectionPayload): Promise<string[]> {
|
||||
return this.requests
|
||||
.request(
|
||||
'workspace',
|
||||
'creationDetectAgents',
|
||||
payload,
|
||||
MobileWebCreationAgentDetectionPayloadSchema,
|
||||
MobileWebCreationAgentDetectionResultSchema
|
||||
)
|
||||
.then((result) => result.agentIds)
|
||||
}
|
||||
|
||||
repoHooks(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationRepoHooksResult> {
|
||||
return this.repoRequest('creationRepoHooks', payload, MobileWebCreationRepoHooksResultSchema)
|
||||
}
|
||||
|
||||
runtimeCapabilities(): Promise<MobileWebCreationRuntimeCapabilitiesResult> {
|
||||
return this.emptyRequest(
|
||||
'creationRuntimeCapabilities',
|
||||
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
|
||||
MobileWebCreationRuntimeCapabilitiesResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
sparsePresets(payload: MobileWebCreationRepoPayload) {
|
||||
return this.requests
|
||||
.request(
|
||||
'workspace',
|
||||
'creationSparsePresets',
|
||||
payload,
|
||||
MobileWebCreationRepoPayloadSchema,
|
||||
MobileWebCreationSparsePresetsResultSchema
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.presets.some((preset) => preset.repoId !== payload.repoId)) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
return result.presets
|
||||
})
|
||||
}
|
||||
|
||||
saveSparsePreset(payload: MobileWebCreationSparsePresetSavePayload) {
|
||||
return this.requests
|
||||
.request(
|
||||
'workspace',
|
||||
'creationSaveSparsePreset',
|
||||
payload,
|
||||
MobileWebCreationSparsePresetSavePayloadSchema,
|
||||
MobileWebCreationSparsePresetSaveResultSchema
|
||||
)
|
||||
.then((result) => {
|
||||
const preset = result.preset
|
||||
if (
|
||||
preset.repoId !== payload.repoId ||
|
||||
(payload.id !== undefined && preset.id !== payload.id) ||
|
||||
preset.name !== payload.name ||
|
||||
!sameStrings(preset.directories, payload.directories)
|
||||
) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
return preset
|
||||
})
|
||||
}
|
||||
|
||||
persistTrust(
|
||||
payload: MobileWebCreationPersistTrustPayload
|
||||
): Promise<MobileWebCreationTrustedHooksResult> {
|
||||
return this.requests.request(
|
||||
'workspace',
|
||||
'creationPersistTrust',
|
||||
payload,
|
||||
MobileWebCreationPersistTrustPayloadSchema,
|
||||
MobileWebCreationTrustedHooksResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
private availability(operation: MobileWebBridgeOperationName<'workspace'>): Promise<boolean> {
|
||||
return this.emptyRequest<{ available: boolean }>(
|
||||
operation,
|
||||
MobileWebCreationAvailabilityPayloadSchema,
|
||||
MobileWebCreationAvailabilityResultSchema
|
||||
).then((result) => result.available)
|
||||
}
|
||||
|
||||
private repoRequest<TResult>(
|
||||
operation: MobileWebBridgeOperationName<'workspace'>,
|
||||
payload: MobileWebCreationRepoPayload,
|
||||
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
|
||||
): Promise<TResult> {
|
||||
return this.requests.request(
|
||||
'workspace',
|
||||
operation,
|
||||
payload,
|
||||
MobileWebCreationRepoPayloadSchema,
|
||||
resultSchema
|
||||
) as Promise<TResult>
|
||||
}
|
||||
|
||||
private emptyRequest<TResult>(
|
||||
operation: MobileWebBridgeOperationName<'workspace'>,
|
||||
payloadSchema: Parameters<MobileWebOneShotRequestClient['request']>[3],
|
||||
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
|
||||
): Promise<TResult> {
|
||||
return this.requests.request(
|
||||
'workspace',
|
||||
operation,
|
||||
{},
|
||||
payloadSchema,
|
||||
resultSchema
|
||||
) as Promise<TResult>
|
||||
}
|
||||
}
|
||||
|
||||
function matchingRepoTarget<TResult extends { targetId: string }>(
|
||||
payload: MobileWebCreationRepoPayload,
|
||||
result: TResult
|
||||
): TResult {
|
||||
if (result.targetId !== payload.repoId) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { z } from 'zod'
|
||||
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
|
||||
import {
|
||||
MobileWebCreationBranchSearchResultSchema,
|
||||
MobileWebCreationGitHubLookupPayloadSchema,
|
||||
MobileWebCreationGitHubLookupResultSchema,
|
||||
MobileWebCreationGitHubRepoLookupPayloadSchema,
|
||||
MobileWebCreationGitHubSearchResultSchema,
|
||||
MobileWebCreationGitLabLookupPayloadSchema,
|
||||
MobileWebCreationGitLabLookupResultSchema,
|
||||
MobileWebCreationGitLabSearchPayloadSchema,
|
||||
MobileWebCreationGitLabSearchResultSchema,
|
||||
MobileWebCreationHostedBaseResultSchema,
|
||||
MobileWebCreationLinearSearchPayloadSchema,
|
||||
MobileWebCreationLinearSearchResultSchema,
|
||||
MobileWebCreationMrBasePayloadSchema,
|
||||
MobileWebCreationPrBasePayloadSchema,
|
||||
MobileWebCreationRepoQueryPayloadSchema,
|
||||
MobileWebCreationRepoSlugResultSchema,
|
||||
type MobileWebCreationGitHubItem,
|
||||
type MobileWebCreationGitLabItem,
|
||||
type MobileWebCreationHostedBaseResult,
|
||||
type MobileWebCreationLinearIssue
|
||||
} from '../../shared/mobile-web/workspace-creation-source-contract'
|
||||
import { MobileWebCreationRepoPayloadSchema } from '../../shared/mobile-web/workspace-creation-read-contract'
|
||||
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
|
||||
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
|
||||
|
||||
export class MobileWebWorkspaceCreationSourceRequestClient {
|
||||
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
|
||||
|
||||
searchGitHub(repoId: string, query: string): Promise<MobileWebCreationGitHubItem[]> {
|
||||
return this.request(
|
||||
'creationSearchGitHub',
|
||||
{ repoId, query },
|
||||
MobileWebCreationRepoQueryPayloadSchema,
|
||||
MobileWebCreationGitHubSearchResultSchema
|
||||
).then((result) => matchingRepoItems(repoId, result.items))
|
||||
}
|
||||
|
||||
searchGitLab(
|
||||
repoId: string,
|
||||
query: string,
|
||||
state: 'opened' | 'merged' | 'closed' | 'all'
|
||||
): Promise<MobileWebCreationGitLabItem[]> {
|
||||
return this.request(
|
||||
'creationSearchGitLab',
|
||||
{ repoId, query, state },
|
||||
MobileWebCreationGitLabSearchPayloadSchema,
|
||||
MobileWebCreationGitLabSearchResultSchema
|
||||
).then((result) => matchingRepoItems(repoId, result.items))
|
||||
}
|
||||
|
||||
searchLinear(
|
||||
query: string,
|
||||
linearWorkspaceId: string | null | undefined
|
||||
): Promise<MobileWebCreationLinearIssue[]> {
|
||||
return this.request(
|
||||
'creationSearchLinear',
|
||||
{ query, linearWorkspaceId },
|
||||
MobileWebCreationLinearSearchPayloadSchema,
|
||||
MobileWebCreationLinearSearchResultSchema
|
||||
).then((result) => result.issues)
|
||||
}
|
||||
|
||||
searchBranches(repoId: string, query: string) {
|
||||
return this.request(
|
||||
'creationSearchBranches',
|
||||
{ repoId, query },
|
||||
MobileWebCreationRepoQueryPayloadSchema,
|
||||
MobileWebCreationBranchSearchResultSchema
|
||||
).then((result) => result.branches)
|
||||
}
|
||||
|
||||
resolveRepoSlug(repoId: string) {
|
||||
return this.request(
|
||||
'creationResolveRepoSlug',
|
||||
{ repoId },
|
||||
MobileWebCreationRepoPayloadSchema,
|
||||
MobileWebCreationRepoSlugResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
lookupGitHub(repoId: string, number: number): Promise<MobileWebCreationGitHubItem | null> {
|
||||
return this.request(
|
||||
'creationLookupGitHub',
|
||||
{ repoId, number },
|
||||
MobileWebCreationGitHubLookupPayloadSchema,
|
||||
MobileWebCreationGitHubLookupResultSchema
|
||||
).then((result) => matchingLookup(result.item, { repoId, number }))
|
||||
}
|
||||
|
||||
lookupGitHubRepo(payload: {
|
||||
repoId: string
|
||||
slug: { owner: string; repo: string; host?: string }
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}): Promise<MobileWebCreationGitHubItem | null> {
|
||||
return this.request(
|
||||
'creationLookupGitHubRepo',
|
||||
payload,
|
||||
MobileWebCreationGitHubRepoLookupPayloadSchema,
|
||||
MobileWebCreationGitHubLookupResultSchema
|
||||
).then((result) => matchingLookup(result.item, payload))
|
||||
}
|
||||
|
||||
lookupGitLab(payload: {
|
||||
repoId: string
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}): Promise<MobileWebCreationGitLabItem | null> {
|
||||
return this.request(
|
||||
'creationLookupGitLab',
|
||||
payload,
|
||||
MobileWebCreationGitLabLookupPayloadSchema,
|
||||
MobileWebCreationGitLabLookupResultSchema
|
||||
).then((result) =>
|
||||
matchingLookup(result.item, {
|
||||
repoId: payload.repoId,
|
||||
number: payload.iid,
|
||||
type: payload.type
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
resolvePrBase(payload: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<MobileWebCreationHostedBaseResult> {
|
||||
return this.request(
|
||||
'creationResolvePrBase',
|
||||
payload,
|
||||
MobileWebCreationPrBasePayloadSchema,
|
||||
MobileWebCreationHostedBaseResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
resolveMrBase(payload: {
|
||||
repoId: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<MobileWebCreationHostedBaseResult> {
|
||||
return this.request(
|
||||
'creationResolveMrBase',
|
||||
payload,
|
||||
MobileWebCreationMrBasePayloadSchema,
|
||||
MobileWebCreationHostedBaseResultSchema
|
||||
)
|
||||
}
|
||||
|
||||
private request<TPayload, TResult>(
|
||||
operation: MobileWebBridgeOperationName<'workspace'>,
|
||||
payload: TPayload,
|
||||
payloadSchema: z.ZodType<TPayload>,
|
||||
resultSchema: z.ZodType<TResult>
|
||||
): Promise<TResult> {
|
||||
return this.requests.request('workspace', operation, payload, payloadSchema, resultSchema)
|
||||
}
|
||||
}
|
||||
|
||||
function matchingRepoItems<TItem extends { repoId: string }>(
|
||||
repoId: string,
|
||||
items: TItem[]
|
||||
): TItem[] {
|
||||
if (items.some((item) => item.repoId !== repoId)) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function matchingLookup<
|
||||
TItem extends { repoId: string; number: number; type: string },
|
||||
TPayload extends { repoId: string; number: number; type?: string }
|
||||
>(item: TItem | null, payload: TPayload): TItem | null {
|
||||
if (
|
||||
item &&
|
||||
(item.repoId !== payload.repoId ||
|
||||
item.number !== payload.number ||
|
||||
(payload.type !== undefined && item.type !== payload.type))
|
||||
) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -66,11 +66,7 @@ const EXPECTED_ECHO_FIELDS: Record<string, readonly string[]> = {
|
||||
'task.loadLinearDetail': ['issue.targetId'],
|
||||
'task.loadLinearIssue': ['issue.targetId'],
|
||||
'task.projectTable': ['project', 'selectedView.id'],
|
||||
'task.resolveProjectRef': ['host'],
|
||||
'workspace.creationSaveSparsePreset': ['directories', 'id', 'name', 'repoId'],
|
||||
'workspace.creationSparsePresets': ['repoId'],
|
||||
'workspace.creationSshConnect': ['targetId'],
|
||||
'workspace.creationSshState': ['targetId']
|
||||
'task.resolveProjectRef': ['host']
|
||||
}
|
||||
|
||||
/** Echo helpers shared across request clients, and the result fields each one compares. Resolved
|
||||
@@ -293,7 +289,7 @@ describe('mobile web bridge operation echo census', () => {
|
||||
)
|
||||
|
||||
expect(Object.keys(EXPECTED_ECHO_FIELDS).filter((key) => !registered.has(key))).toEqual([])
|
||||
expect(Object.keys(EXPECTED_ECHO_FIELDS).length).toBeGreaterThanOrEqual(46)
|
||||
expect(Object.keys(EXPECTED_ECHO_FIELDS).length).toBeGreaterThanOrEqual(42)
|
||||
})
|
||||
|
||||
it('guards the page workspace handle on every workspace-scoped echo it records', () => {
|
||||
@@ -301,6 +297,6 @@ describe('mobile web bridge operation echo census', () => {
|
||||
fields.some((field) => field === 'workspaceId')
|
||||
)
|
||||
|
||||
expect(workspaceScoped.length).toBeGreaterThanOrEqual(37)
|
||||
expect(workspaceScoped.length).toBeGreaterThanOrEqual(34)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('mobile web bridge operation registry census', () => {
|
||||
}
|
||||
|
||||
expect(files.length).toBeGreaterThanOrEqual(40)
|
||||
expect(named.size).toBeGreaterThanOrEqual(123)
|
||||
expect(named.size).toBeGreaterThanOrEqual(119)
|
||||
expect([...named].filter((pair) => !registered.has(pair))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('mobile web bridge operation registry census', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(pairs.size).toBeGreaterThanOrEqual(123)
|
||||
expect(pairs.size).toBeGreaterThanOrEqual(119)
|
||||
expect([...pairs].filter(([, schemas]) => schemas.size !== 1).map(([key]) => key)).toEqual([])
|
||||
expect([...pairs.keys()].filter((key) => !registered.has(key))).toEqual([])
|
||||
expect(
|
||||
|
||||
@@ -9,30 +9,6 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = {
|
||||
hostSubscribe: 'subscription',
|
||||
hostRequest: 'mutation',
|
||||
snapshot: 'read',
|
||||
creationRepositories: 'read',
|
||||
creationRetiredNames: 'read',
|
||||
creationSettings: 'read',
|
||||
creationTrustedHooks: 'read',
|
||||
creationGitLabAvailability: 'read',
|
||||
creationLinearAvailability: 'read',
|
||||
creationSshState: 'read',
|
||||
creationSshConnect: 'mutation',
|
||||
creationDetectAgents: 'read',
|
||||
creationRepoHooks: 'read',
|
||||
creationRuntimeCapabilities: 'read',
|
||||
creationSparsePresets: 'read',
|
||||
creationSaveSparsePreset: 'mutation',
|
||||
creationPersistTrust: 'mutation',
|
||||
creationSearchGitHub: 'read',
|
||||
creationSearchGitLab: 'read',
|
||||
creationSearchLinear: 'read',
|
||||
creationSearchBranches: 'read',
|
||||
creationResolveRepoSlug: 'read',
|
||||
creationLookupGitHub: 'read',
|
||||
creationLookupGitHubRepo: 'read',
|
||||
creationLookupGitLab: 'read',
|
||||
creationResolvePrBase: 'read',
|
||||
creationResolveMrBase: 'read',
|
||||
creationCreateBlank: 'mutation',
|
||||
creationCreateFromSource: 'mutation'
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { MobileWebCreationTrustedHooksResultSchema } from './workspace-creation-read-contract'
|
||||
import { MobileWebCreationTrustedHooksResultSchema } from './workspace-creation-repo-trust-contract'
|
||||
|
||||
const EmptyPayloadSchema = z.object({}).strict()
|
||||
const TaskProviderSchema = z.enum(['github', 'gitlab', 'linear'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { MobileWebCreationRepoIdSchema } from './workspace-creation-read-contract'
|
||||
import { MobileWebCreationRepoIdSchema } from './workspace-creation-repo-trust-contract'
|
||||
|
||||
const NameSchema = z.string().min(1).max(160)
|
||||
const OptionalTextSchema = z.string().max(4096).optional()
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { isMobileWebSha256 } from './protocol-token-contract'
|
||||
|
||||
export type MobileWebExecutionHostId = 'local' | `ssh:${string}` | `runtime:${string}`
|
||||
|
||||
export const MobileWebCreationRepoIdSchema = z.string().min(1).max(128)
|
||||
const EmptyPayloadSchema = z.object({}).strict()
|
||||
|
||||
const TrustedHookEntrySchema = z
|
||||
.object({
|
||||
contentHash: z.string().refine(isMobileWebSha256),
|
||||
approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
.strict()
|
||||
const TrustedHookRepoSchema = z
|
||||
.object({
|
||||
all: z
|
||||
.object({ approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) })
|
||||
.strict()
|
||||
.optional(),
|
||||
setup: TrustedHookEntrySchema.optional(),
|
||||
archive: TrustedHookEntrySchema.optional(),
|
||||
issueCommand: TrustedHookEntrySchema.optional(),
|
||||
vmRecipe: TrustedHookEntrySchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationRepositoriesPayloadSchema = EmptyPayloadSchema
|
||||
export const MobileWebCreationRepositoriesResultSchema = z
|
||||
.object({
|
||||
repositories: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: MobileWebCreationRepoIdSchema,
|
||||
displayName: z.string().min(1).max(240),
|
||||
path: z.string().max(4_096),
|
||||
badgeColor: z.string().max(64).optional(),
|
||||
connectionId: MobileWebCreationRepoIdSchema.nullable().optional(),
|
||||
executionHostId: z.custom<MobileWebExecutionHostId>(
|
||||
(value) =>
|
||||
value === 'local' ||
|
||||
(typeof value === 'string' &&
|
||||
(value.startsWith('ssh:executionHost_') ||
|
||||
value.startsWith('runtime:executionHost_')))
|
||||
),
|
||||
executionHostLabel: z.string().min(1).max(240),
|
||||
projectId: z.string().min(1).max(128),
|
||||
upstream: z
|
||||
.object({
|
||||
owner: z.string().min(1).max(240),
|
||||
repo: z.string().min(1).max(240),
|
||||
host: z.string().min(1).max(240).optional()
|
||||
})
|
||||
.strict()
|
||||
.nullable()
|
||||
.optional(),
|
||||
kind: z.enum(['git', 'folder']).optional()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(10_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationSettingsPayloadSchema = EmptyPayloadSchema
|
||||
export const MobileWebCreationSettingsResultSchema = z
|
||||
.object({
|
||||
defaultTuiAgent: z.string().min(1).max(64).nullable().optional(),
|
||||
disabledTuiAgents: z.array(z.string().min(1).max(64)).max(64).optional(),
|
||||
visibleTaskProviders: z
|
||||
.array(z.enum(['github', 'gitlab', 'linear']))
|
||||
.max(3)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationTrustedHooksPayloadSchema = EmptyPayloadSchema
|
||||
export const MobileWebCreationTrustedHooksResultSchema = z.record(
|
||||
MobileWebCreationRepoIdSchema,
|
||||
TrustedHookRepoSchema
|
||||
)
|
||||
|
||||
export const MobileWebCreationAvailabilityPayloadSchema = EmptyPayloadSchema
|
||||
export const MobileWebCreationAvailabilityResultSchema = z
|
||||
.object({ available: z.boolean() })
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationRepoPayloadSchema = z
|
||||
.object({ repoId: MobileWebCreationRepoIdSchema })
|
||||
.strict()
|
||||
export const MobileWebCreationRetiredNamesResultSchema = z
|
||||
.object({
|
||||
exhaustedTiers: z.number().int().nonnegative().max(999_999),
|
||||
names: z.array(z.string().min(1).max(240)).max(1_000)
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationAgentDetectionPayloadSchema = z
|
||||
.object({ repoId: MobileWebCreationRepoIdSchema.nullable() })
|
||||
.strict()
|
||||
export const MobileWebCreationAgentDetectionResultSchema = z
|
||||
.object({ agentIds: z.array(z.string().min(1).max(64)).max(64) })
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationSshStateResultSchema = z
|
||||
.object({
|
||||
targetId: MobileWebCreationRepoIdSchema,
|
||||
status: z.enum([
|
||||
'disconnected',
|
||||
'connecting',
|
||||
'auth-failed',
|
||||
'deploying-relay',
|
||||
'connected',
|
||||
'reconnecting',
|
||||
'reconnection-failed',
|
||||
'error'
|
||||
]),
|
||||
error: z.string().max(160).nullable(),
|
||||
reconnectAttempt: z.number().int().nonnegative().max(1_000_000),
|
||||
supportsFolderDownload: z.boolean().optional(),
|
||||
remotePlatform: z.enum(['linux', 'darwin', 'win32']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationRepoHooksResultSchema = z
|
||||
.object({
|
||||
hooks: z
|
||||
.object({
|
||||
scripts: z
|
||||
.object({
|
||||
setup: z
|
||||
.string()
|
||||
.max(64 * 1024)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
source: z.string().max(80).nullable(),
|
||||
setupRunPolicy: z.enum(['ask', 'run-by-default', 'skip-by-default']).optional(),
|
||||
setupTrust: z
|
||||
.object({
|
||||
contentHash: z.string().refine(isMobileWebSha256),
|
||||
scriptContent: z.string().max(64 * 1024)
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationRuntimeCapabilitiesPayloadSchema = EmptyPayloadSchema
|
||||
export const MobileWebCreationRuntimeCapabilitiesResultSchema = z
|
||||
.object({
|
||||
tasksSupported: z.boolean(),
|
||||
idempotentWorktreeCreateSupported: z.boolean(),
|
||||
worktreeCreateIdempotency: z
|
||||
.object({ dedupeTtlMs: z.number().int().nonnegative() })
|
||||
.strict()
|
||||
.or(z.literal(false)),
|
||||
hostPlatform: z
|
||||
.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationSparsePresetsResultSchema = z
|
||||
.object({
|
||||
presets: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1).max(240),
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
name: z.string().min(1).max(240),
|
||||
directories: z.array(z.string().min(1).max(4_096)).max(1_000),
|
||||
createdAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(1_000)
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationSparsePresetSavePayloadSchema = z
|
||||
.object({
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
id: z.string().min(1).max(240).optional(),
|
||||
name: z.string().trim().min(1).max(240),
|
||||
directories: z.array(z.string().min(1).max(4_096)).min(1).max(1_000)
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationSparsePresetSaveResultSchema = z
|
||||
.object({ preset: MobileWebCreationSparsePresetsResultSchema.shape.presets.element })
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationPersistTrustPayloadSchema = z
|
||||
.object({
|
||||
trust: MobileWebCreationTrustedHooksResultSchema,
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
contentHash: z.string().refine(isMobileWebSha256),
|
||||
alwaysTrust: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type MobileWebCreationRepositoriesResult = z.infer<
|
||||
typeof MobileWebCreationRepositoriesResultSchema
|
||||
>
|
||||
export type MobileWebCreationSettingsResult = z.infer<typeof MobileWebCreationSettingsResultSchema>
|
||||
export type MobileWebCreationRuntimeCapabilitiesResult = z.infer<
|
||||
typeof MobileWebCreationRuntimeCapabilitiesResultSchema
|
||||
>
|
||||
export type MobileWebCreationTrustedHooksResult = z.infer<
|
||||
typeof MobileWebCreationTrustedHooksResultSchema
|
||||
>
|
||||
export type MobileWebCreationRepoPayload = z.infer<typeof MobileWebCreationRepoPayloadSchema>
|
||||
export type MobileWebCreationRetiredNamesResult = z.infer<
|
||||
typeof MobileWebCreationRetiredNamesResultSchema
|
||||
>
|
||||
export type MobileWebCreationAgentDetectionPayload = z.infer<
|
||||
typeof MobileWebCreationAgentDetectionPayloadSchema
|
||||
>
|
||||
export type MobileWebCreationSshStateResult = z.infer<typeof MobileWebCreationSshStateResultSchema>
|
||||
export type MobileWebCreationRepoHooksResult = z.infer<
|
||||
typeof MobileWebCreationRepoHooksResultSchema
|
||||
>
|
||||
export type MobileWebCreationPersistTrustPayload = z.infer<
|
||||
typeof MobileWebCreationPersistTrustPayloadSchema
|
||||
>
|
||||
export type MobileWebCreationSparsePresetSavePayload = z.infer<
|
||||
typeof MobileWebCreationSparsePresetSavePayloadSchema
|
||||
>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
import { isMobileWebSha256 } from './protocol-token-contract'
|
||||
|
||||
export const MobileWebCreationRepoIdSchema = z.string().min(1).max(128)
|
||||
|
||||
const TrustedHookEntrySchema = z
|
||||
.object({
|
||||
contentHash: z.string().refine(isMobileWebSha256),
|
||||
approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const TrustedHookRepoSchema = z
|
||||
.object({
|
||||
all: z
|
||||
.object({ approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) })
|
||||
.strict()
|
||||
.optional(),
|
||||
setup: TrustedHookEntrySchema.optional(),
|
||||
archive: TrustedHookEntrySchema.optional(),
|
||||
issueCommand: TrustedHookEntrySchema.optional(),
|
||||
vmRecipe: TrustedHookEntrySchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationTrustedHooksResultSchema = z.record(
|
||||
MobileWebCreationRepoIdSchema,
|
||||
TrustedHookRepoSchema
|
||||
)
|
||||
|
||||
export type MobileWebCreationTrustedHooksResult = z.infer<
|
||||
typeof MobileWebCreationTrustedHooksResultSchema
|
||||
>
|
||||
@@ -1,187 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { MobileWebCreationRepoIdSchema } from './workspace-creation-read-contract'
|
||||
|
||||
const QuerySchema = z.string().max(2048)
|
||||
const TitleSchema = z.string().min(1).max(512)
|
||||
const UrlSchema = z.string().url().max(2048)
|
||||
const OptionalBranchSchema = z.string().min(1).max(512).optional()
|
||||
const IssueNumberSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||
|
||||
export const MobileWebCreationRepoQueryPayloadSchema = z
|
||||
.object({ repoId: MobileWebCreationRepoIdSchema, query: QuerySchema })
|
||||
.strict()
|
||||
export const MobileWebCreationGitLabSearchPayloadSchema =
|
||||
MobileWebCreationRepoQueryPayloadSchema.extend({
|
||||
state: z.enum(['opened', 'merged', 'closed', 'all'])
|
||||
}).strict()
|
||||
export const MobileWebCreationLinearSearchPayloadSchema = z
|
||||
.object({
|
||||
query: QuerySchema,
|
||||
linearWorkspaceId: z.string().min(1).max(256).nullable().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationGitHubItemSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
type: z.enum(['issue', 'pr']),
|
||||
number: IssueNumberSchema,
|
||||
title: TitleSchema,
|
||||
state: z.enum(['open', 'closed', 'merged', 'draft']),
|
||||
url: UrlSchema,
|
||||
labels: z.array(z.string().max(120)).max(100),
|
||||
updatedAt: z.string().max(80),
|
||||
author: z.string().max(160).nullable(),
|
||||
branchName: OptionalBranchSchema,
|
||||
baseRefName: OptionalBranchSchema,
|
||||
isCrossRepository: z.boolean().optional(),
|
||||
repoId: MobileWebCreationRepoIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationGitLabItemSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
type: z.enum(['issue', 'mr']),
|
||||
number: IssueNumberSchema,
|
||||
title: TitleSchema,
|
||||
state: z.enum(['opened', 'closed', 'merged', 'locked', 'draft']),
|
||||
url: UrlSchema,
|
||||
labels: z.array(z.string().max(120)).max(100),
|
||||
updatedAt: z.string().max(80),
|
||||
author: z.string().max(160).nullable(),
|
||||
branchName: OptionalBranchSchema,
|
||||
baseRefName: OptionalBranchSchema,
|
||||
isCrossRepository: z.boolean().optional(),
|
||||
repoId: MobileWebCreationRepoIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationLinearIssueSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
workspaceId: z.string().min(1).max(256).optional(),
|
||||
identifier: z.string().min(1).max(80),
|
||||
title: TitleSchema,
|
||||
branchName: OptionalBranchSchema,
|
||||
url: UrlSchema,
|
||||
state: z
|
||||
.object({
|
||||
name: z.string().max(120),
|
||||
type: z.string().max(80),
|
||||
color: z.string().max(64)
|
||||
})
|
||||
.strict(),
|
||||
team: z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
name: z.string().max(160),
|
||||
key: z.string().max(40)
|
||||
})
|
||||
.strict(),
|
||||
labels: z.array(z.string().max(120)).max(100),
|
||||
labelIds: z.array(z.string().max(256)).max(100),
|
||||
priority: z.number().int().min(0).max(10),
|
||||
updatedAt: z.string().max(80)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationGitHubSearchResultSchema = z
|
||||
.object({ items: z.array(MobileWebCreationGitHubItemSchema).max(50) })
|
||||
.strict()
|
||||
export const MobileWebCreationGitLabSearchResultSchema = z
|
||||
.object({ items: z.array(MobileWebCreationGitLabItemSchema).max(50) })
|
||||
.strict()
|
||||
export const MobileWebCreationLinearSearchResultSchema = z
|
||||
.object({ issues: z.array(MobileWebCreationLinearIssueSchema).max(50) })
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationBranchResultSchema = z
|
||||
.object({
|
||||
refName: z.string().min(1).max(512),
|
||||
localBranchName: z.string().max(512)
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationBranchSearchResultSchema = z
|
||||
.object({ branches: z.array(MobileWebCreationBranchResultSchema).max(20) })
|
||||
.strict()
|
||||
|
||||
export const MobileWebCreationRepoSlugResultSchema = z
|
||||
.object({
|
||||
supported: z.boolean(),
|
||||
slug: z
|
||||
.object({
|
||||
owner: z.string().min(1).max(256),
|
||||
repo: z.string().min(1).max(256),
|
||||
host: z.string().min(1).max(256).optional()
|
||||
})
|
||||
.strict()
|
||||
.nullable()
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationGitHubLookupPayloadSchema = z
|
||||
.object({ repoId: MobileWebCreationRepoIdSchema, number: IssueNumberSchema })
|
||||
.strict()
|
||||
export const MobileWebCreationGitHubRepoLookupPayloadSchema = z
|
||||
.object({
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
slug: MobileWebCreationRepoSlugResultSchema.shape.slug.unwrap(),
|
||||
number: IssueNumberSchema,
|
||||
type: z.enum(['issue', 'pr'])
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationGitLabLookupPayloadSchema = z
|
||||
.object({
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
host: z.string().min(1).max(256),
|
||||
path: z.string().min(1).max(1024),
|
||||
iid: IssueNumberSchema,
|
||||
type: z.enum(['issue', 'mr'])
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationGitHubLookupResultSchema = z
|
||||
.object({ item: MobileWebCreationGitHubItemSchema.nullable() })
|
||||
.strict()
|
||||
export const MobileWebCreationGitLabLookupResultSchema = z
|
||||
.object({ item: MobileWebCreationGitLabItemSchema.nullable() })
|
||||
.strict()
|
||||
|
||||
const HostedBaseShape = {
|
||||
baseBranch: z.string().min(1).max(512),
|
||||
compareBaseRef: z.string().min(1).max(512).optional(),
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string().min(1).max(256),
|
||||
branchName: z.string().min(1).max(512)
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
branchNameOverride: z.string().min(1).max(512).optional(),
|
||||
maintainerCanModify: z.boolean().optional()
|
||||
} as const
|
||||
export const MobileWebCreationHostedBaseResultSchema = z.object(HostedBaseShape).strict()
|
||||
export const MobileWebCreationPrBasePayloadSchema = z
|
||||
.object({
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
prNumber: IssueNumberSchema,
|
||||
headRefName: OptionalBranchSchema,
|
||||
baseRefName: OptionalBranchSchema,
|
||||
isCrossRepository: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebCreationMrBasePayloadSchema = z
|
||||
.object({
|
||||
repoId: MobileWebCreationRepoIdSchema,
|
||||
mrIid: IssueNumberSchema,
|
||||
sourceBranch: OptionalBranchSchema,
|
||||
targetBranch: OptionalBranchSchema,
|
||||
isCrossRepository: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type MobileWebCreationGitHubItem = z.infer<typeof MobileWebCreationGitHubItemSchema>
|
||||
export type MobileWebCreationGitLabItem = z.infer<typeof MobileWebCreationGitLabItemSchema>
|
||||
export type MobileWebCreationLinearIssue = z.infer<typeof MobileWebCreationLinearIssueSchema>
|
||||
export type MobileWebCreationHostedBaseResult = z.infer<
|
||||
typeof MobileWebCreationHostedBaseResultSchema
|
||||
>
|
||||
Reference in New Issue
Block a user