diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 0167692c868..c96087976ff 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -4,6 +4,13 @@ import { MOBILE_WEB_SESSION_CAPABILITIES_METHOD } from './mobile-web-session-cap import { MOBILE_WEB_SESSION_QUICK_COMMAND_METHODS } from './mobile-web-session-quick-commands' import { MOBILE_WEB_SESSION_BROWSER_CREATE_METHOD } from './mobile-web-session-browser-create' import { MOBILE_WEB_SOURCE_CONTROL_READ_METHODS } from './mobile-web-source-control-reads' +import { MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS } from './mobile-web-source-control-history' +import { MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS } from './mobile-web-source-control-compare' +import { MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS } from './mobile-web-source-control-repository' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS } from './mobile-web-source-control-review-metadata' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS } from './mobile-web-source-control-review-link' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS } from './mobile-web-source-control-review-diff' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS } from './mobile-web-source-control-review-terminal-send' import { MOBILE_WEB_SESSION_TERMINAL_CREATION_METHODS } from './mobile-web-session-terminal-creation' import { MOBILE_WEB_NATIVE_CHAT_FILE_METHODS } from './mobile-web-native-chat-files' import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions' @@ -116,6 +123,13 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...UPDATER_METHODS, ...MOBILE_WEB_FILE_READ_METHODS, ...MOBILE_WEB_SOURCE_CONTROL_READ_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS, MOBILE_WEB_FILE_WATCH_METHOD, ...MOBILE_WEB_TERMINAL_ACTION_METHODS, ...MOBILE_WEB_NATIVE_CHAT_METHODS, diff --git a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts index 73535dccf59..8a62646aa2c 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts @@ -4,6 +4,33 @@ import { mobileWebHostUnsubscribeMethod } from '../../../../shared/mobile-web/ho export const MOBILE_WEB_HOST_RPC_METHODS = new Set([ 'mobileWeb.sourceControl.status', 'mobileWeb.sourceControl.diff', + 'mobileWeb.sourceControl.repositoryState', + 'mobileWeb.sourceControl.branches', + 'mobileWeb.sourceControl.history', + 'mobileWeb.sourceControl.branchCompare', + 'mobileWeb.sourceControl.commitCompare', + 'mobileWeb.sourceControl.reviewMetadata', + 'mobileWeb.sourceControl.reviewMetadataUpdate', + 'mobileWeb.sourceControl.reviewLink', + 'mobileWeb.sourceControl.reviewLinkUpdate', + 'mobileWeb.sourceControl.reviewDiff', + 'mobileWeb.sourceControl.reviewTerminalSend', + 'git.stage', + 'git.bulkStage', + 'git.unstage', + 'git.bulkUnstage', + 'git.discard', + 'git.bulkDiscard', + 'git.commit', + 'git.checkout', + 'git.fetch', + 'git.pull', + 'git.fastForward', + 'git.push', + 'git.rebaseFromBase', + 'git.abortMerge', + 'git.abortRebase', + 'files.openDiff', 'mobileWeb.files.readDir', 'files.readChunk', 'mobileWeb.files.searchPaths', diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-compare.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-compare.ts new file mode 100644 index 00000000000..74b59e95654 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-compare.ts @@ -0,0 +1,51 @@ +import { defineMethod } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { + MobileWebGitObjectIdSchema, + MobileWebGitRefNameSchema +} from '../../../../shared/mobile-web/source-control-history-contract' +import { + projectMobileWebBranchCompare, + projectMobileWebCommitCompare +} from '../../../../shared/mobile-web/source-control-history-presentation' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' + +const branchCompare = sourceControlHostMethod('git.branchCompare') +const commitCompare = sourceControlHostMethod('git.commitCompare') + +export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.branchCompare', + params: MobileWebWorktreeScope.extend({ baseRef: MobileWebGitRefNameSchema }), + handler: async (params, context) => + withoutMobileWebWorkspaceId( + projectMobileWebBranchCompare( + await branchCompare.handler( + { worktree: params.worktree, baseRef: params.baseRef }, + context + ), + MOBILE_WEB_PAGE_IDENTITY, + params.baseRef + ) + ) + }), + defineMethod({ + name: 'mobileWeb.sourceControl.commitCompare', + params: MobileWebWorktreeScope.extend({ commitId: MobileWebGitObjectIdSchema }), + handler: async (params, context) => + withoutMobileWebWorkspaceId( + projectMobileWebCommitCompare( + await commitCompare.handler( + { worktree: params.worktree, commitId: params.commitId }, + context + ), + MOBILE_WEB_PAGE_IDENTITY, + params.commitId + ) + ) + }) +] diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-diff-clip.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-diff-clip.ts new file mode 100644 index 00000000000..53711084103 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-diff-clip.ts @@ -0,0 +1,19 @@ +export const MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES = 512 * 1024 + +/** Escaped line text can exceed the byte budget even within the row-count limit the page asked + * for, so the last rows are dropped until the page fits and the page resumes from `nextOffset`. */ +export function clipMobileWebDiffResult(page: T): T { + const rows = 'rows' in page ? (page as { rows: unknown }).rows : undefined + if (!Array.isArray(rows) || typeof (page as { offset?: unknown }).offset !== 'number') { + return page + } + const clipped = page as T & { offset: number; rows: unknown[]; nextOffset: number | null } + while ( + Buffer.byteLength(JSON.stringify(clipped)) > MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES && + clipped.rows.length > 1 + ) { + clipped.rows.pop() + clipped.nextOffset = clipped.offset + clipped.rows.length + } + return clipped +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-history.test.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-history.test.ts new file mode 100644 index 00000000000..996f6430f59 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-history.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import { GIT_METHODS } from './git' +import { MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS } from './mobile-web-source-control-compare' +import { MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS } from './mobile-web-source-control-history' + +const context = { signal: new AbortController().signal } as RpcContext +const worktree = 'id:private-host-workspace' +const OID = 'a'.repeat(40) + +function fixture(name: string, sourceMethod: string, raw: unknown) { + const source = GIT_METHODS.find((method) => method.name === sourceMethod)! + const handler = vi.spyOn(source, 'handler').mockResolvedValue(raw) + const method = [ + ...MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS + ].find((entry) => entry.name === name)! + return { + handler, + run: async (params: Record = {}) => + method.handler(method.params!.parse({ worktree, ...params }), context) + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('bounded host Source Control history reads', () => { + it('caps a branch list the page cannot render and reports the true total', async () => { + const f = fixture('mobileWeb.sourceControl.branches', 'git.localBranches', { + current: 'main', + branches: Array.from({ length: 500 }, (_, index) => `branch-${index}`) + }) + const result = (await f.run()) as { branches: string[]; totalCount: number; truncated: boolean } + expect(result.branches).toHaveLength(128) + expect(result).toMatchObject({ totalCount: 500, truncated: true }) + expect(JSON.stringify(result)).not.toContain('workspaceId') + expect(f.handler).toHaveBeenCalledWith({ worktree }, context) + }) + + it('drops history items that would overrun the bridge budget and marks the page incomplete', async () => { + const raw = { + items: Array.from({ length: 100 }, (_, index) => ({ + id: index.toString(16).padStart(40, '0'), + parentIds: [], + subject: 'subject', + message: 'x'.repeat(16 * 1024), + references: [] + })), + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 100 + } + const f = fixture('mobileWeb.sourceControl.history', 'git.history', raw) + const result = (await f.run({ limit: 100 })) as { + items: { message: string }[] + hasMore: boolean + } + expect(result.items.length).toBeLessThan(100) + expect(result.hasMore).toBe(true) + expect(result.items[0]!.message).toHaveLength(8 * 1024) + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(192 * 1024) + expect(f.handler).toHaveBeenCalledWith({ worktree, limit: 100 }, context) + }) + + it('forwards the requested base ref and defaults the history limit', async () => { + const f = fixture('mobileWeb.sourceControl.history', 'git.history', { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + }) + await f.run({ baseRef: 'origin/main' }) + expect(f.handler).toHaveBeenCalledWith({ worktree, limit: 50, baseRef: 'origin/main' }, context) + await expect(f.run({ baseRef: '--upload-pack=evil' })).rejects.toThrow() + }) +}) + +describe('bounded host Source Control compares', () => { + it('clips a branch compare to the response budget and keeps the reported file count', async () => { + const raw = { + summary: { + baseRef: 'main', + baseOid: OID, + compareRef: 'HEAD', + headOid: 'b'.repeat(40), + mergeBase: OID, + changedFiles: 6_000, + status: 'ready' + }, + entries: Array.from({ length: 6_000 }, (_, index) => ({ + path: `src/${'deep/'.repeat(8)}file-${index}.ts`, + status: 'modified' + })) + } + const f = fixture('mobileWeb.sourceControl.branchCompare', 'git.branchCompare', raw) + const result = (await f.run({ baseRef: 'main' })) as { + entries: unknown[] + changedFiles: number + truncated: boolean + } + expect(result.entries.length).toBeLessThan(4_000) + expect(result).toMatchObject({ changedFiles: 6_000, truncated: true }) + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(192 * 1024) + expect(f.handler).toHaveBeenCalledWith({ worktree, baseRef: 'main' }, context) + }) + + it('answers a commit compare in one page and refuses a short commit id', async () => { + const f = fixture('mobileWeb.sourceControl.commitCompare', 'git.commitCompare', { + summary: { + commitOid: OID, + parentOid: null, + compareRef: 'HEAD', + baseRef: 'parent', + changedFiles: 1, + status: 'ready' + }, + entries: [{ path: 'src/app.ts', status: 'modified', added: 2, removed: 1 }] + }) + await expect(f.run({ commitId: OID })).resolves.toMatchObject({ + commitId: OID, + entries: [{ relativePath: 'src/app.ts', added: 2, removed: 1 }], + truncated: false + }) + await expect(f.run({ commitId: 'abc1234' })).rejects.toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-history.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-history.ts new file mode 100644 index 00000000000..4fe88dffd67 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-history.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { defineMethod } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { + MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT, + MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT, + MobileWebGitRefNameSchema +} from '../../../../shared/mobile-web/source-control-history-contract' +import { + projectMobileWebBranches, + projectMobileWebHistory +} from '../../../../shared/mobile-web/source-control-history-presentation' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' + +const branches = sourceControlHostMethod('git.localBranches') +const history = sourceControlHostMethod('git.history') + +export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.branches', + params: MobileWebWorktreeScope, + handler: async (params, context) => + withoutMobileWebWorkspaceId( + projectMobileWebBranches( + await branches.handler({ worktree: params.worktree }, context), + MOBILE_WEB_PAGE_IDENTITY + ) + ) + }), + defineMethod({ + name: 'mobileWeb.sourceControl.history', + params: MobileWebWorktreeScope.extend({ + limit: z + .number() + .int() + .min(1) + .max(MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT) + .default(MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT), + baseRef: MobileWebGitRefNameSchema.optional() + }), + handler: async (params, context) => + withoutMobileWebWorkspaceId( + projectMobileWebHistory( + await history.handler( + { + worktree: params.worktree, + limit: params.limit, + ...(params.baseRef === undefined ? {} : { baseRef: params.baseRef }) + }, + context + ), + MOBILE_WEB_PAGE_IDENTITY, + params.limit + ) + ) + }) +] diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-host-method.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-host-method.ts new file mode 100644 index 00000000000..2679c8c4ad6 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-host-method.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { isStreamingMethod, type RpcMethod } from '../core' +import { GIT_METHODS } from './git' +import { REPO_METHODS } from './repo' +import { WORKTREE_METHODS } from './worktree' + +export const MobileWebWorktreeScope = z.object({ worktree: z.string().min(1).max(4096) }) + +/** The page never sees a workspace handle in a wrapper result: the shell rewrote its own handle + * into `worktree`, so the projection carries this placeholder and the page restores its handle. */ +export const MOBILE_WEB_PAGE_IDENTITY = 'page' + +const HOST_METHODS = new Map( + [...GIT_METHODS, ...WORKTREE_METHODS, ...REPO_METHODS].map((method) => [method.name, method]) +) + +export function sourceControlHostMethod(name: string): RpcMethod { + const method = HOST_METHODS.get(name) + if (!method || isStreamingMethod(method)) { + throw new Error(`Missing source control host method: ${name}`) + } + return method +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-reads.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-reads.ts index 17621782952..61d96a944ec 100644 --- a/src/main/runtime/rpc/methods/mobile-web-source-control-reads.ts +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-reads.ts @@ -1,6 +1,9 @@ -import { z } from 'zod' -import { defineMethod, isStreamingMethod } from '../core' -import { GIT_METHODS } from './git' +import { defineMethod } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' import { MobileWebSourceControlDiffPayloadSchema, MobileWebSourceControlStatusPayloadSchema @@ -9,36 +12,28 @@ import { sanitizeMobileWebSourceControlDiff, sanitizeMobileWebSourceControlStatus } from '../../../../shared/mobile-web/source-control-host-presentation' +import { + clipMobileWebDiffResult, + MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES +} from './mobile-web-source-control-diff-clip' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' -const Worktree = z.string().min(1).max(4096) -const PAGE_IDENTITY = 'page' -const MAX_RESULT_BYTES = 512 * 1024 - -function sourceMethod(name: string) { - const method = GIT_METHODS.find((entry) => entry.name === name) - if (!method || isStreamingMethod(method)) { - throw new Error(`Missing unary source control method: ${name}`) - } - return method -} -const status = sourceMethod('git.status') -const diff = sourceMethod('git.diff') +const status = sourceControlHostMethod('git.status') +const diff = sourceControlHostMethod('git.diff') export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [ defineMethod({ name: 'mobileWeb.sourceControl.status', - params: MobileWebSourceControlStatusPayloadSchema.omit({ workspaceId: true }).extend({ - worktree: Worktree - }), + params: MobileWebSourceControlStatusPayloadSchema.omit({ workspaceId: true }).extend( + MobileWebWorktreeScope.shape + ), handler: async (params, context) => { const raw = await status.handler({ worktree: params.worktree, reuseLineStats: true }, context) - const { workspaceId: _workspaceId, ...result } = sanitizeMobileWebSourceControlStatus( - raw, - PAGE_IDENTITY, - params.limit + const result = withoutMobileWebWorkspaceId( + sanitizeMobileWebSourceControlStatus(raw, MOBILE_WEB_PAGE_IDENTITY, params.limit) ) while ( - Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES && + Buffer.byteLength(JSON.stringify(result)) > MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES && result.entries.length ) { result.entries.pop() @@ -49,9 +44,9 @@ export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [ }), defineMethod({ name: 'mobileWeb.sourceControl.diff', - params: MobileWebSourceControlDiffPayloadSchema.omit({ workspaceId: true }).extend({ - worktree: Worktree - }), + params: MobileWebSourceControlDiffPayloadSchema.omit({ workspaceId: true }).extend( + MobileWebWorktreeScope.shape + ), handler: async (params, context) => { const raw = await diff.handler( { @@ -61,21 +56,14 @@ export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [ }, context ) - const { workspaceId: _workspaceId, ...result } = sanitizeMobileWebSourceControlDiff(raw, { - ...params, - workspaceId: PAGE_IDENTITY - }) - // Escaped line text can exceed the byte budget even within the row-count limit. - if (result.kind === 'text') { - while ( - Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES && - result.rows.length > 1 - ) { - result.rows.pop() - result.nextOffset = result.offset + result.rows.length - } - } - return result + return clipMobileWebDiffResult( + withoutMobileWebWorkspaceId( + sanitizeMobileWebSourceControlDiff(raw, { + ...params, + workspaceId: MOBILE_WEB_PAGE_IDENTITY + }) + ) + ) } }) ] diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-repository.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-repository.ts new file mode 100644 index 00000000000..78c910ae95b --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-repository.ts @@ -0,0 +1,53 @@ +import { defineMethod, type RpcContext } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { projectMobileWebRepositoryState } from '../../../../shared/mobile-web/source-control-repository-presentation' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' + +const status = sourceControlHostMethod('git.status') +const upstream = sourceControlHostMethod('git.upstreamStatus') +const worktreeShow = sourceControlHostMethod('worktree.show') +const repoBaseRefDefault = sourceControlHostMethod('repo.baseRefDefault') + +export const MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.repositoryState', + params: MobileWebWorktreeScope, + handler: async (params, context) => { + const [statusResult, upstreamResult, baseRef] = await Promise.all([ + status.handler({ worktree: params.worktree }, context), + upstream.handler({ worktree: params.worktree }, context), + resolveBaseRef(params.worktree, context) + ]) + return withoutMobileWebWorkspaceId( + projectMobileWebRepositoryState({ + status: statusResult, + upstream: upstreamResult, + baseRef, + workspaceId: MOBILE_WEB_PAGE_IDENTITY + }) + ) + } + }) +] + +/** The workspace ref wins; the project default only fills in a workspace that never pinned one. */ +async function resolveBaseRef(worktree: string, context: RpcContext): Promise { + const shown = await worktreeShow.handler({ worktree }, context) + const record = isRecord(shown) && isRecord(shown.worktree) ? shown.worktree : undefined + if (typeof record?.baseRef === 'string' && record.baseRef.length > 0) { + return record.baseRef + } + if (typeof record?.repoId !== 'string' || record.repoId.length === 0) { + return null + } + const fallback = await repoBaseRefDefault.handler({ repo: `id:${record.repoId}` }, context) + return isRecord(fallback) ? fallback.defaultBaseRef : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-review-diff.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-review-diff.ts new file mode 100644 index 00000000000..f7c324bc687 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-review-diff.ts @@ -0,0 +1,58 @@ +import { defineMethod } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { + MobileWebSourceControlReviewDiffShape, + rejectMissingReviewCompareIdentity +} from '../../../../shared/mobile-web/source-control-review-contract' +import { sanitizeMobileWebSourceControlDiff } from '../../../../shared/mobile-web/source-control-host-presentation' +import { clipMobileWebDiffResult } from './mobile-web-source-control-diff-clip' + +const diff = sourceControlHostMethod('git.diff') +const branchDiff = sourceControlHostMethod('git.branchDiff') + +export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.reviewDiff', + params: MobileWebWorktreeScope.extend(MobileWebSourceControlReviewDiffShape) + .strict() + .superRefine(rejectMissingReviewCompareIdentity), + handler: async (params, context) => { + const raw = + params.scope === 'branch' + ? await branchDiff.handler( + { + worktree: params.worktree, + filePath: params.relativePath, + ...(params.oldRelativePath ? { oldPath: params.oldRelativePath } : {}), + compare: params.compare + }, + context + ) + : await diff.handler( + { + worktree: params.worktree, + filePath: params.relativePath, + staged: params.scope === 'staged' + }, + context + ) + const { + workspaceId: _workspaceId, + area: _area, + ...page + } = sanitizeMobileWebSourceControlDiff(raw, { + workspaceId: MOBILE_WEB_PAGE_IDENTITY, + relativePath: params.relativePath, + area: params.scope === 'staged' ? 'staged' : 'unstaged', + offset: params.offset, + limit: params.limit, + ...(params.expectedRevision ? { expectedRevision: params.expectedRevision } : {}) + }) + return clipMobileWebDiffResult({ ...page, scope: params.scope }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-review-link.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-review-link.ts new file mode 100644 index 00000000000..33c0459c676 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-review-link.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { defineMethod, type RpcContext } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { + MobileWebSourceControlReviewLinkUpdatePayloadSchema, + type MobileWebSourceControlReviewLinkResult +} from '../../../../shared/mobile-web/source-control-review-contract' +import { + mobileWebReviewLinkWorktreeField, + projectMobileWebReviewLink +} from '../../../../shared/mobile-web/source-control-review-presentation' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' + +const worktreeShow = sourceControlHostMethod('worktree.show') +const worktreeSet = sourceControlHostMethod('worktree.set') + +const UpdateParams = MobileWebWorktreeScope.extend( + MobileWebSourceControlReviewLinkUpdatePayloadSchema.omit({ workspaceId: true }).shape +) + +export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.reviewLink', + params: MobileWebWorktreeScope, + handler: async (params, context) => + withoutMobileWebWorkspaceId(await readReviewLink(params.worktree, context)) + }), + defineMethod({ + name: 'mobileWeb.sourceControl.reviewLinkUpdate', + params: UpdateParams, + handler: async (params, context) => { + await worktreeSet.handler( + { + worktree: params.worktree, + ...mobileWebReviewLinkWorktreeField(params.provider, params.number), + ...(params.baseRef ? { baseRef: params.baseRef } : {}) + }, + context + ) + return withoutMobileWebWorkspaceId(await readReviewLink(params.worktree, context)) + } + }) +] + +async function readReviewLink( + worktree: string, + context: RpcContext +): Promise { + const shown = await worktreeShow.handler({ worktree }, context) + const record = z.object({ worktree: z.unknown() }).parse(shown).worktree + return projectMobileWebReviewLink(record, MOBILE_WEB_PAGE_IDENTITY) +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-review-metadata.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-review-metadata.ts new file mode 100644 index 00000000000..d738e0afefb --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-review-metadata.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { defineMethod, type RpcContext } from '../core' +import { + MOBILE_WEB_PAGE_IDENTITY, + MobileWebWorktreeScope, + sourceControlHostMethod +} from './mobile-web-source-control-host-method' +import { + MobileWebSourceControlReviewMetadataUpdateShape, + rejectDuplicateReviewMetadataKeys, + type MobileWebSourceControlReviewMetadataResult +} from '../../../../shared/mobile-web/source-control-review-contract' +import { + mobileWebReviewMetadataWorktreeFields, + projectMobileWebReviewMetadata +} from '../../../../shared/mobile-web/source-control-review-presentation' +import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id' + +const worktreeShow = sourceControlHostMethod('worktree.show') +const worktreeSet = sourceControlHostMethod('worktree.set') + +const UpdateParams = MobileWebWorktreeScope.extend(MobileWebSourceControlReviewMetadataUpdateShape) + .strict() + .superRefine(rejectDuplicateReviewMetadataKeys) + +export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.reviewMetadata', + params: MobileWebWorktreeScope, + handler: async (params, context) => + withoutMobileWebWorkspaceId(await readReviewMetadata(params.worktree, context)) + }), + defineMethod({ + name: 'mobileWeb.sourceControl.reviewMetadataUpdate', + params: UpdateParams, + handler: async (params, context) => { + const current = await readReviewMetadata(params.worktree, context) + if (current.revision !== params.expectedRevision) { + throw new Error('conflict') + } + // worktree.set has no compare-and-set, so another writer can still win after this read. + await worktreeSet.handler( + { + worktree: params.worktree, + ...mobileWebReviewMetadataWorktreeFields({ + worktreeId: worktreeIdFromSelector(params.worktree), + comments: params.comments, + reviewState: params.reviewState + }) + }, + context + ) + return withoutMobileWebWorkspaceId(await readReviewMetadata(params.worktree, context)) + } + }) +] + +async function readReviewMetadata( + worktree: string, + context: RpcContext +): Promise { + const shown = await worktreeShow.handler({ worktree }, context) + const record = z.object({ worktree: z.unknown() }).parse(shown).worktree + return projectMobileWebReviewMetadata(record, MOBILE_WEB_PAGE_IDENTITY) +} + +function worktreeIdFromSelector(worktree: string): string { + return worktree.startsWith('id:') ? worktree.slice('id:'.length) : worktree +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-review-terminal-send.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-review-terminal-send.ts new file mode 100644 index 00000000000..c7d63aa9dc0 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-review-terminal-send.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { defineMethod, isStreamingMethod, type RpcContext } from '../core' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { MobileWebWorktreeScope } from './mobile-web-source-control-host-method' +import { MOBILE_WEB_REVIEW_TERMINAL_TEXT_MAX_CHARACTERS } from '../../../../shared/mobile-web/source-control-review-contract' + +const Tab = z.object({ + id: z.string(), + type: z.literal('terminal'), + status: z.literal('ready'), + terminal: z.string().min(1).max(256) +}) + +const send = TERMINAL_SEND_METHODS.find((method) => method.name === 'terminal.send') +if (!send || isStreamingMethod(send)) { + throw new Error('Missing terminal.send method') +} +const terminalSend = send + +export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS = [ + defineMethod({ + name: 'mobileWeb.sourceControl.reviewTerminalSend', + params: MobileWebWorktreeScope.extend({ + tabId: z.string().min(1).max(512), + text: z.string().min(1).max(MOBILE_WEB_REVIEW_TERMINAL_TEXT_MAX_CHARACTERS) + }), + handler: async (params, context) => { + if (!context.clientId) { + throw new Error('runtime_unavailable') + } + // The handle comes from the host tab list for this worktree, never from the request. + const terminal = await resolveTerminal(context, params.worktree, params.tabId) + const result = await terminalSend.handler( + terminalSend.params!.parse({ + terminal, + text: params.text, + enter: true, + client: { id: context.clientId, type: 'mobile' } + }), + context + ) + return { accepted: acceptedSend(result) } + } + }) +] + +async function resolveTerminal( + context: RpcContext, + worktree: string, + tabId: string +): Promise { + const snapshot = await context.runtime.listMobileSessionTabs(worktree, context.pairedDeviceId) + if (`id:${snapshot.worktree}` !== worktree) { + throw new Error('selector_not_found') + } + const parsed = Tab.safeParse(snapshot.tabs.find((tab) => tab.id === tabId)) + if (!parsed.success) { + throw new Error('selector_not_found') + } + return parsed.data.terminal +} + +function acceptedSend(result: unknown): boolean { + const parsed = z.object({ send: z.object({ accepted: z.boolean() }) }).safeParse(result) + if (!parsed.success) { + throw new Error('host_error') + } + return parsed.data.send.accepted +} diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-review.test.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-review.test.ts new file mode 100644 index 00000000000..1d33c34ae38 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-review.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcAnyMethod, RpcContext, RpcMethod } from '../core' +import { GIT_METHODS } from './git' +import { REPO_METHODS } from './repo' +import { WORKTREE_METHODS } from './worktree' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS } from './mobile-web-source-control-repository' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS } from './mobile-web-source-control-review-metadata' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS } from './mobile-web-source-control-review-link' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS } from './mobile-web-source-control-review-diff' +import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS } from './mobile-web-source-control-review-terminal-send' +import { mobileWebReviewMetadataRevision } from '../../../../shared/mobile-web/source-control-review-presentation' + +const worktree = 'id:private-host-workspace' +const OID = 'a'.repeat(40) +const METHODS = [ + ...MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS, + ...MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS +] + +function hostMethod(name: string): RpcAnyMethod { + return [...GIT_METHODS, ...WORKTREE_METHODS, ...REPO_METHODS, ...TERMINAL_SEND_METHODS].find( + (method) => method.name === name + )! +} + +function stub(name: string, result: unknown) { + return vi.spyOn(hostMethod(name) as RpcMethod, 'handler').mockResolvedValue(result) +} + +async function run( + name: string, + params: Record = {}, + context?: Partial +) { + const method = METHODS.find((entry) => entry.name === name)! + return method.handler(method.params!.parse({ worktree, ...params }), { + signal: new AbortController().signal, + ...context + } as RpcContext) +} + +const comment = { + id: 'comment-1', + relativePath: 'src/app.ts', + lineNumber: 4, + body: 'needs a test', + createdAt: 1, + side: 'modified' as const +} + +afterEach(() => vi.restoreAllMocks()) + +describe('host repository state', () => { + it('composes status, upstream and the workspace base ref into one page-shaped read', async () => { + stub('git.status', { head: OID, branch: 'main', conflictOperation: 'none' }) + stub('git.upstreamStatus', { hasUpstream: true, ahead: 2, behind: 0, upstreamName: 'origin/x' }) + stub('worktree.show', { worktree: { id: 'wt', repoId: 'repo-1', baseRef: 'origin/main' } }) + await expect(run('mobileWeb.sourceControl.repositoryState')).resolves.toEqual({ + head: OID, + branch: 'main', + conflictOperation: 'unknown', + baseRef: 'origin/main', + upstream: { + hasUpstream: true, + upstreamName: 'origin/x', + ahead: 2, + behind: 0, + hasConfiguredPushTarget: false, + behindCommitsArePatchEquivalent: false + } + }) + }) + + it('falls back to the project default when the workspace pinned no base ref', async () => { + stub('git.status', { head: null, branch: 'main', conflictOperation: 'rebase' }) + stub('git.upstreamStatus', { hasUpstream: false, ahead: 0, behind: 0 }) + stub('worktree.show', { worktree: { id: 'wt', repoId: 'repo-1', path: '/private/repo' } }) + const baseRefDefault = stub('repo.baseRefDefault', { defaultBaseRef: 'origin/trunk' }) + const result = await run('mobileWeb.sourceControl.repositoryState') + expect(result).toMatchObject({ baseRef: 'origin/trunk', conflictOperation: 'rebase' }) + expect(baseRefDefault.mock.calls[0]![0]).toEqual({ repo: 'id:repo-1' }) + expect(JSON.stringify(result)).not.toContain('private') + }) +}) + +describe('host review metadata', () => { + it('projects only review fields off the workspace record', async () => { + stub('worktree.show', { + worktree: { + id: 'wt', + path: '/private/repo', + setupScript: 'curl evil', + diffComments: [{ ...comment, filePath: 'src/app.ts' }], + mobileDiffReview: { version: 1, files: {} } + } + }) + const result = (await run('mobileWeb.sourceControl.reviewMetadata')) as { + comments: unknown[] + revision: string + } + expect(result.comments).toEqual([comment]) + expect(JSON.stringify(result)).not.toMatch(/private|setupScript|workspaceId/) + }) + + it('refuses a stale write and sends only review fields to the workspace record', async () => { + const record = { + id: 'wt', + path: '/private/repo', + diffComments: [], + mobileDiffReview: { version: 1, files: {} } + } + stub('worktree.show', { worktree: record }) + const set = stub('worktree.set', { worktree: record }) + const reviewState = { version: 1 as const, files: [] } + const revision = mobileWebReviewMetadataRevision({ + comments: [], + reviewState: { version: 1, files: [] } + }) + await expect( + run('mobileWeb.sourceControl.reviewMetadataUpdate', { + expectedRevision: 'b'.repeat(64), + comments: [], + reviewState + }) + ).rejects.toThrow('conflict') + expect(set).not.toHaveBeenCalled() + await run('mobileWeb.sourceControl.reviewMetadataUpdate', { + expectedRevision: revision, + comments: [comment], + reviewState + }) + expect(set.mock.calls[0]![0]).toEqual({ + worktree, + diffComments: [ + { + id: 'comment-1', + worktreeId: 'private-host-workspace', + filePath: 'src/app.ts', + lineNumber: 4, + body: 'needs a test', + createdAt: 1, + side: 'modified' + } + ], + mobileDiffReview: { version: 1, files: {} } + }) + }) + + it('rejects a write that names another workspace', async () => { + stub('worktree.show', { worktree: { id: 'wt' } }) + await expect( + run('mobileWeb.sourceControl.reviewMetadataUpdate', { + expectedRevision: 'b'.repeat(64), + comments: [], + reviewState: { version: 1, files: [] }, + workspaceId: 'other-workspace' + }) + ).rejects.toThrow() + }) +}) + +describe('host review link', () => { + it('reads the linked review numbers and writes one provider field', async () => { + const record = { id: 'wt', path: '/private/repo', baseRef: 'main', linkedGitLabMR: 7 } + stub('worktree.show', { worktree: record }) + const set = stub('worktree.set', { worktree: record }) + await expect(run('mobileWeb.sourceControl.reviewLink')).resolves.toEqual({ + baseRef: 'main', + linkedGitHubPR: null, + linkedGitLabMR: 7, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null + }) + await run('mobileWeb.sourceControl.reviewLinkUpdate', { provider: 'github', number: 12 }) + expect(set.mock.calls[0]![0]).toEqual({ worktree, linkedPR: 12 }) + }) +}) + +describe('host review diff', () => { + it('pages a staged diff and refuses a branch diff without compare identity', async () => { + stub('git.diff', { kind: 'text', originalContent: 'old\n', modifiedContent: 'new\n' }) + await expect( + run('mobileWeb.sourceControl.reviewDiff', { relativePath: 'src/app.ts', scope: 'staged' }) + ).resolves.toMatchObject({ kind: 'text', scope: 'staged', relativePath: 'src/app.ts' }) + await expect( + run('mobileWeb.sourceControl.reviewDiff', { relativePath: 'src/app.ts', scope: 'branch' }) + ).rejects.toThrow() + }) +}) + +describe('host review terminal send', () => { + it('resolves the terminal from the requested workspace tab list', async () => { + const send = stub('terminal.send', { send: { accepted: true } }) + const listMobileSessionTabs = vi.fn().mockResolvedValue({ + worktree: 'private-host-workspace', + tabs: [{ id: 'tab-1', type: 'terminal', status: 'ready', terminal: 'terminal-1' }] + }) + await expect( + run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, { + clientId: 'device-1', + runtime: { listMobileSessionTabs } + } as unknown as RpcContext) + ).resolves.toEqual({ accepted: true }) + expect(send.mock.calls[0]![0]).toMatchObject({ + terminal: 'terminal-1', + text: 'review this', + enter: true, + client: { id: 'device-1', type: 'mobile' } + }) + }) + + it('refuses a tab that belongs to another workspace', async () => { + const send = stub('terminal.send', { send: { accepted: true } }) + const listMobileSessionTabs = vi.fn().mockResolvedValue({ + worktree: 'other-workspace', + tabs: [{ id: 'tab-1', type: 'terminal', status: 'ready', terminal: 'terminal-1' }] + }) + await expect( + run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, { + clientId: 'device-1', + runtime: { listMobileSessionTabs } + } as unknown as RpcContext) + ).rejects.toThrow('selector_not_found') + expect(send).not.toHaveBeenCalled() + }) + + it('refuses a tab id the requested workspace does not list', async () => { + const send = stub('terminal.send', { send: { accepted: true } }) + const listMobileSessionTabs = vi.fn().mockResolvedValue({ + worktree: 'private-host-workspace', + tabs: [{ id: 'tab-2', type: 'terminal', status: 'ready', terminal: 'terminal-2' }] + }) + await expect( + run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, { + clientId: 'device-1', + runtime: { listMobileSessionTabs } + } as unknown as RpcContext) + ).rejects.toThrow('selector_not_found') + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-source-control-workspace-id.ts b/src/main/runtime/rpc/methods/mobile-web-source-control-workspace-id.ts new file mode 100644 index 00000000000..4d6c09b0f31 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-source-control-workspace-id.ts @@ -0,0 +1,8 @@ +/** Strips the placeholder identity a shared page projection carries, so the wrapper result never + * asserts a workspace handle the Desktop does not own. */ +export function withoutMobileWebWorkspaceId( + value: T +): Omit { + const { workspaceId: _workspaceId, ...rest } = value + return rest +} diff --git a/src/main/runtime/runtime-rpc-mobile-web-source-control-mutations.test.ts b/src/main/runtime/runtime-rpc-mobile-web-source-control-mutations.test.ts new file mode 100644 index 00000000000..d5da1451b82 --- /dev/null +++ b/src/main/runtime/runtime-rpc-mobile-web-source-control-mutations.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { DeviceRegistry } from './device-registry' +import { createMobileRpcSurfaceRuntime } from './runtime-rpc-mobile-method-allowlist-fixtures' + +/** Every Source Control write the page reaches through the generic host lane, with the runtime + * call each one must land on. The shell rewrote the page handle into `worktree`; nothing else in + * the request may choose the workspace. */ +const MUTATIONS: [string, string, Record, unknown[]][] = [ + ['git.stage', 'stageRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']], + [ + 'git.bulkStage', + 'bulkStageRuntimeGitPaths', + { filePaths: ['src/app.ts'] }, + ['id:bound', ['src/app.ts']] + ], + ['git.unstage', 'unstageRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']], + [ + 'git.bulkUnstage', + 'bulkUnstageRuntimeGitPaths', + { filePaths: ['src/app.ts'] }, + ['id:bound', ['src/app.ts']] + ], + ['git.discard', 'discardRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']], + [ + 'git.bulkDiscard', + 'bulkDiscardRuntimeGitPaths', + { filePaths: ['src/app.ts'] }, + ['id:bound', ['src/app.ts']] + ], + ['git.commit', 'commitRuntimeGit', { message: 'feat: mobile' }, ['id:bound', 'feat: mobile']], + ['git.checkout', 'checkoutRuntimeGitBranch', { branch: 'main' }, ['id:bound', 'main']], + ['git.fetch', 'fetchRuntimeGit', {}, ['id:bound']], + ['git.pull', 'pullRuntimeGit', {}, ['id:bound']], + ['git.fastForward', 'fastForwardRuntimeGit', {}, ['id:bound']], + ['git.push', 'pushRuntimeGit', { publish: true }, ['id:bound', true]], + ['git.rebaseFromBase', 'rebaseRuntimeGitFromBase', { baseRef: 'main' }, ['id:bound', 'main']], + ['git.abortMerge', 'abortRuntimeGitMerge', {}, ['id:bound']], + ['git.abortRebase', 'abortRuntimeGitRebase', {}, ['id:bound']], + [ + 'files.openDiff', + 'openMobileDiff', + { relativePath: 'src/app.ts', staged: true }, + ['id:bound', 'src/app.ts', true] + ] +] + +it('binds every page-reachable Source Control write to the shell-supplied worktree', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-source-control-')) + const { runtime } = createMobileRpcSurfaceRuntime() + const calls = new Map>() + for (const [, runtimeMethod] of MUTATIONS) { + const spy = vi.fn().mockResolvedValue({ ok: true, branch: 'main', success: true }) + calls.set(runtimeMethod, spy) + Object.assign(runtime, { [runtimeMethod]: spy }) + } + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile') + async function dispatch(method: string, params: unknown) { + const responses: { ok: boolean; error?: { code: string } }[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ id: 'request', method, params, deviceToken: mobile.token }), + (response) => responses.push(JSON.parse(response)), + () => {} + ) + return responses[0]! + } + try { + for (const [method, runtimeMethod, params, expected] of MUTATIONS) { + // A second workspace field in the body must not redirect the write. + const response = await dispatch(method, { + ...params, + worktree: 'id:bound', + worktreeId: 'id:other', + workspaceId: 'other' + }) + expect(response.error, method).toBeUndefined() + expect( + calls.get(runtimeMethod)!.mock.calls.at(-1)?.slice(0, expected.length), + method + ).toEqual(expected) + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/src/shared/mobile-web/source-control-commit-contract.test.ts b/src/shared/mobile-web/source-control-commit-contract.test.ts index b91e7dd04ba..1785347a7f4 100644 --- a/src/shared/mobile-web/source-control-commit-contract.test.ts +++ b/src/shared/mobile-web/source-control-commit-contract.test.ts @@ -1,59 +1,58 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS, - MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT, + MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS, MobileWebSourceControlCommitPayloadSchema, + MobileWebSourceControlCommitResultSchema, MobileWebSourceControlGenerateCommitMessageResultSchema } from './source-control-commit-contract' -const staged = { - relativePath: 'src/app.ts', - status: 'modified' as const, - area: 'staged' as const -} - describe('mobile web source-control commit contract', () => { - it('accepts a bounded commit tied to a full HEAD and staged snapshot', () => { + it('accepts a bounded commit message', () => { expect( MobileWebSourceControlCommitPayloadSchema.parse({ workspaceId: 'workspace-1', - expectedHead: 'a'.repeat(40), - stagedEntries: [staged], message: 'feat: add mobile commit' }) - ).toMatchObject({ stagedEntries: [staged] }) + ).toEqual({ workspaceId: 'workspace-1', message: 'feat: add mobile commit' }) }) - it('rejects blank or oversized messages and unsafe staged snapshots', () => { + it('rejects blank, oversized and unexpectedly extended commit requests', () => { for (const candidate of [ - { message: ' ', stagedEntries: [staged] }, - { - message: 'x'.repeat(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS + 1), - stagedEntries: [staged] - }, - { message: 'feat: duplicate', stagedEntries: [staged, staged] }, - { - message: 'feat: unresolved', - stagedEntries: [{ ...staged, conflictStatus: 'unresolved' }] - }, - { - message: 'feat: too many', - stagedEntries: Array.from( - { length: MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT + 1 }, - (_, index) => ({ ...staged, relativePath: `src/${index}.ts` }) - ) - } + { message: ' ' }, + { message: 'x'.repeat(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS + 1) }, + { message: 'feat: amend', amend: true } ]) { - expect(() => - MobileWebSourceControlCommitPayloadSchema.parse({ + expect( + MobileWebSourceControlCommitPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: 'a'.repeat(40), ...candidate - }) - ).toThrow() + }).success + ).toBe(false) } }) + it('reads a refused commit from the result and bounds its error text', () => { + expect( + MobileWebSourceControlCommitResultSchema.parse({ + success: false, + error: 'pre-commit hook failed' + }) + ).toEqual({ success: false, error: 'pre-commit hook failed' }) + expect( + MobileWebSourceControlCommitResultSchema.safeParse({ + success: false, + error: 'x'.repeat(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS + 1) + }).success + ).toBe(false) + expect( + MobileWebSourceControlCommitResultSchema.safeParse({ + success: true, + hostPath: '/private/repo' + }).success + ).toBe(false) + }) + it('bounds generated messages and strips undeclared host fields', () => { expect(() => MobileWebSourceControlGenerateCommitMessageResultSchema.parse({ diff --git a/src/shared/mobile-web/source-control-commit-contract.ts b/src/shared/mobile-web/source-control-commit-contract.ts index 4b67e3145e0..90c984013dd 100644 --- a/src/shared/mobile-web/source-control-commit-contract.ts +++ b/src/shared/mobile-web/source-control-commit-contract.ts @@ -1,48 +1,38 @@ import { z } from 'zod' import { MobileWebWorkspaceIdSchema } from './bridge-operation-contract' import { isMobileWebGitObjectId } from './protocol-token-contract' -import { MobileWebSourceControlMutationEntrySchema } from './source-control-mutation-contract' export const MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS = 10_000 export const MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS = 2_000 export const MOBILE_WEB_COMMIT_AGENT_LABEL_MAX_CHARACTERS = 160 -export const MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT = 64 const FullGitObjectIdSchema = z.string().refine(isMobileWebGitObjectId) -export const MobileWebSourceControlCommitEntrySchema = - MobileWebSourceControlMutationEntrySchema.extend({ - area: z.literal('staged') - }).superRefine((entry, context) => { - if (entry.conflictStatus === 'unresolved') { - context.addIssue({ code: 'custom', message: 'Unresolved entries cannot be committed' }) - } - }) - -const CommitSnapshotShape = { - workspaceId: MobileWebWorkspaceIdSchema, - expectedHead: FullGitObjectIdSchema, - stagedEntries: z - .array(MobileWebSourceControlCommitEntrySchema) - .min(1) - .max(MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT) -} as const - export const MobileWebSourceControlCommitPayloadSchema = z .object({ - ...CommitSnapshotShape, + workspaceId: MobileWebWorkspaceIdSchema, message: z .string() .max(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS) .refine((message) => message.trim().length > 0, 'Commit message is required') }) .strict() - .superRefine(validateUniqueStagedEntries) + +/** The Desktop reports a refused commit in the result, not as an RPC error, so the page reads both + * outcomes from the same shape it would get on a native route. */ +export const MobileWebSourceControlCommitResultSchema = z + .object({ + success: z.boolean(), + error: z.string().max(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS).optional() + }) + .strict() export const MobileWebSourceControlGenerateCommitMessagePayloadSchema = z - .object(CommitSnapshotShape) + .object({ + workspaceId: MobileWebWorkspaceIdSchema, + expectedHead: FullGitObjectIdSchema + }) .strict() - .superRefine(validateUniqueStagedEntries) export const MobileWebSourceControlCancelCommitMessagePayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema }) @@ -53,23 +43,6 @@ const CommitResultIdentityShape = { previousHead: FullGitObjectIdSchema } as const -export const MobileWebSourceControlCommitResultSchema = z.discriminatedUnion('status', [ - z - .object({ - ...CommitResultIdentityShape, - status: z.literal('committed'), - head: FullGitObjectIdSchema.nullable() - }) - .strict(), - z - .object({ - ...CommitResultIdentityShape, - status: z.literal('failed'), - error: z.string().min(1).max(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS) - }) - .strict() -]) - export const MobileWebSourceControlGenerateCommitMessageResultSchema = z.discriminatedUnion( 'status', [ @@ -104,41 +77,21 @@ export const MobileWebSourceControlCancelCommitMessageResultSchema = z }) .strict() -export type MobileWebSourceControlCommitEntry = z.infer< - typeof MobileWebSourceControlCommitEntrySchema -> export type MobileWebSourceControlCommitPayload = z.infer< typeof MobileWebSourceControlCommitPayloadSchema > +export type MobileWebSourceControlCommitResult = z.infer< + typeof MobileWebSourceControlCommitResultSchema +> export type MobileWebSourceControlGenerateCommitMessagePayload = z.infer< typeof MobileWebSourceControlGenerateCommitMessagePayloadSchema > export type MobileWebSourceControlCancelCommitMessagePayload = z.infer< typeof MobileWebSourceControlCancelCommitMessagePayloadSchema > -export type MobileWebSourceControlCommitResult = z.infer< - typeof MobileWebSourceControlCommitResultSchema -> export type MobileWebSourceControlGenerateCommitMessageResult = z.infer< typeof MobileWebSourceControlGenerateCommitMessageResultSchema > export type MobileWebSourceControlCancelCommitMessageResult = z.infer< typeof MobileWebSourceControlCancelCommitMessageResultSchema > - -function validateUniqueStagedEntries( - payload: { stagedEntries: readonly { relativePath: string }[] }, - context: z.RefinementCtx -): void { - const paths = new Set() - payload.stagedEntries.forEach((entry, index) => { - if (paths.has(entry.relativePath)) { - context.addIssue({ - code: 'custom', - message: 'Duplicate staged path', - path: ['stagedEntries', index, 'relativePath'] - }) - } - paths.add(entry.relativePath) - }) -} diff --git a/src/shared/mobile-web/source-control-history-contract.test.ts b/src/shared/mobile-web/source-control-history-contract.test.ts index 6b07d91942b..6bafe89a862 100644 --- a/src/shared/mobile-web/source-control-history-contract.test.ts +++ b/src/shared/mobile-web/source-control-history-contract.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT, - MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT, + MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES, MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT, MobileWebSourceControlBranchComparePayloadSchema, MobileWebSourceControlBranchCompareResultSchema, @@ -87,22 +87,24 @@ describe('mobile web source-control history contract', () => { baseOid: OID, headOid: 'b'.repeat(40), mergeBase: OID, - changedFiles: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT, + changedFiles: MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES, status: 'ready', - revision: 'c'.repeat(64), - offset: 0, - totalEntries: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT, entries: Array.from( - { length: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT }, + { length: MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES }, (_, index) => ({ relativePath: `src/file-${index}.ts`, status: 'modified' }) ), - nextOffset: null, truncated: false } expect(MobileWebSourceControlBranchCompareResultSchema.safeParse(result).success).toBe(true) + expect( + MobileWebSourceControlBranchCompareResultSchema.safeParse({ + ...result, + entries: [...result.entries, { relativePath: 'src/extra.ts', status: 'modified' }] + }).success + ).toBe(false) expect( MobileWebSourceControlBranchCompareResultSchema.safeParse({ ...result, diff --git a/src/shared/mobile-web/source-control-history-contract.ts b/src/shared/mobile-web/source-control-history-contract.ts index 791054e5299..3527127b0da 100644 --- a/src/shared/mobile-web/source-control-history-contract.ts +++ b/src/shared/mobile-web/source-control-history-contract.ts @@ -3,14 +3,13 @@ import { MobileWebRelativePathSchema, MobileWebWorkspaceIdSchema } from './bridge-operation-contract' -import { isMobileWebGitObjectId, isMobileWebSha256 } from './protocol-token-contract' +import { isMobileWebGitObjectId } from './protocol-token-contract' export const MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT = 128 export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT = 50 export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT = 100 export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_PARENT_LIMIT = 16 export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_REFERENCE_LIMIT = 32 -export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT = 128 export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES = 4_000 export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES = 192 * 1024 export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES = 192 * 1024 @@ -98,15 +97,7 @@ export const MobileWebSourceControlHistoryResultSchema = z export const MobileWebSourceControlBranchComparePayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema, - baseRef: MobileWebGitRefNameSchema, - offset: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES).default(0), - limit: z - .number() - .int() - .min(1) - .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT) - .default(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT), - expectedRevision: z.string().refine(isMobileWebSha256).optional() + baseRef: MobileWebGitRefNameSchema }) .strict() @@ -138,18 +129,9 @@ export const MobileWebSourceControlBranchCompareResultSchema = z changedFiles: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), commitsAhead: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), status: z.enum(['ready', 'invalid-base', 'unborn-head', 'no-merge-base', 'error']), - revision: z.string().refine(isMobileWebSha256), - offset: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES), - totalEntries: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES), entries: z .array(MobileWebSourceControlCompareEntrySchema) - .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT), - nextOffset: z - .number() - .int() - .min(1) - .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES) - .nullable(), + .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES), truncated: z.boolean() }) .strict() @@ -166,7 +148,7 @@ export const MobileWebSourceControlCommitCompareResultSchema = z status: z.enum(['ready', 'invalid-commit', 'error']), entries: z .array(MobileWebSourceControlCompareEntrySchema) - .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT), + .max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES), truncated: z.boolean() }) .strict() diff --git a/mobile/src/mobile-web/mobile-web-source-control-history-item-sanitizer.ts b/src/shared/mobile-web/source-control-history-item-presentation.ts similarity index 97% rename from mobile/src/mobile-web/mobile-web-source-control-history-item-sanitizer.ts rename to src/shared/mobile-web/source-control-history-item-presentation.ts index 57613a2cc8b..a27f7b86aca 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-history-item-sanitizer.ts +++ b/src/shared/mobile-web/source-control-history-item-presentation.ts @@ -6,7 +6,7 @@ import { MobileWebSourceControlHistoryRefSchema, type MobileWebSourceControlHistoryItem, type MobileWebSourceControlHistoryRef -} from '../../../src/shared/mobile-web/source-control-history-contract' +} from './source-control-history-contract' export function sanitizeMobileWebHistoryItem( candidate: unknown diff --git a/src/shared/mobile-web/source-control-history-presentation.ts b/src/shared/mobile-web/source-control-history-presentation.ts new file mode 100644 index 00000000000..7bba40d866d --- /dev/null +++ b/src/shared/mobile-web/source-control-history-presentation.ts @@ -0,0 +1,249 @@ +import { + MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT, + MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES, + MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES, + MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES, + MobileWebGitObjectIdSchema, + MobileWebGitRefNameSchema, + MobileWebSourceControlBranchCompareResultSchema, + MobileWebSourceControlBranchesResultSchema, + MobileWebSourceControlCommitCompareResultSchema, + MobileWebSourceControlCompareEntrySchema, + MobileWebSourceControlHistoryResultSchema, + type MobileWebSourceControlBranchCompareResult, + type MobileWebSourceControlBranchesResult, + type MobileWebSourceControlCommitCompareResult, + type MobileWebSourceControlCompareEntry, + type MobileWebSourceControlHistoryItem, + type MobileWebSourceControlHistoryResult +} from './source-control-history-contract' +import { MobileWebBrokerError } from './bridge-operation-error' +import { + sanitizeMobileWebHistoryItem, + sanitizeMobileWebHistoryRef +} from './source-control-history-item-presentation' + +const RESPONSE_BUDGET_RESERVE_BYTES = 8 * 1024 + +export function projectMobileWebBranches( + result: unknown, + workspaceId: string +): MobileWebSourceControlBranchesResult { + if (!isRecord(result) || !Array.isArray(result.branches)) { + throw new MobileWebBrokerError('host_error') + } + const branches = result.branches + .slice(0, MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT) + .flatMap((candidate) => { + const branch = safeGitRef(candidate) + return branch ? [branch] : [] + }) + return MobileWebSourceControlBranchesResultSchema.parse({ + workspaceId, + current: safeGitRef(result.current), + branches, + totalCount: result.branches.length, + truncated: branches.length < result.branches.length + }) +} + +export function projectMobileWebHistory( + result: unknown, + workspaceId: string, + limit: number +): MobileWebSourceControlHistoryResult { + if (!isRecord(result) || !Array.isArray(result.items)) { + throw new MobileWebBrokerError('host_error') + } + const items: MobileWebSourceControlHistoryItem[] = [] + let retainedBytes = 0 + let droppedByBudget = false + for (const candidate of result.items.slice(0, limit)) { + const item = sanitizeMobileWebHistoryItem(candidate) + if (!item) { + continue + } + const nextBytes = encodedByteLength(item) + 1 + if ( + retainedBytes + nextBytes > + MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES - RESPONSE_BUDGET_RESERVE_BYTES + ) { + droppedByBudget = true + break + } + retainedBytes += nextBytes + items.push(item) + } + const currentRef = sanitizeMobileWebHistoryRef(result.currentRef) + const remoteRef = sanitizeMobileWebHistoryRef(result.remoteRef) + const baseRef = sanitizeMobileWebHistoryRef(result.baseRef) + const mergeBase = safeObjectId(result.mergeBase) + return MobileWebSourceControlHistoryResultSchema.parse({ + workspaceId, + items, + ...(currentRef ? { currentRef } : {}), + ...(remoteRef ? { remoteRef } : {}), + ...(baseRef ? { baseRef } : {}), + ...(mergeBase ? { mergeBase } : {}), + hasIncomingChanges: result.hasIncomingChanges === true, + hasOutgoingChanges: result.hasOutgoingChanges === true, + hasMore: + result.hasMore === true || + droppedByBudget || + result.items.length > limit || + items.length < Math.min(result.items.length, limit), + limit + }) +} + +export function projectMobileWebBranchCompare( + result: unknown, + workspaceId: string, + baseRef: string +): MobileWebSourceControlBranchCompareResult { + const summary = compareSummary(result) + const page = compareEntryPage(result) + const changedFiles = Math.max(page.reportedCount, safeNonnegativeInteger(summary.changedFiles)) + const commitsAhead = optionalNonnegativeInteger(summary.commitsAhead) + return MobileWebSourceControlBranchCompareResultSchema.parse({ + workspaceId, + baseRef, + compareRef: boundedString(summary.compareRef, 240) ?? 'HEAD', + baseOid: safeObjectId(summary.baseOid), + headOid: safeObjectId(summary.headOid), + mergeBase: safeObjectId(summary.mergeBase), + changedFiles, + ...(commitsAhead === undefined ? {} : { commitsAhead }), + status: branchCompareStatus(summary.status), + entries: page.entries, + truncated: page.truncated || changedFiles > page.entries.length + }) +} + +export function projectMobileWebCommitCompare( + result: unknown, + workspaceId: string, + commitId: string +): MobileWebSourceControlCommitCompareResult { + const summary = compareSummary(result) + const page = compareEntryPage(result) + const changedFiles = Math.max(page.reportedCount, safeNonnegativeInteger(summary.changedFiles)) + return MobileWebSourceControlCommitCompareResultSchema.parse({ + workspaceId, + commitId, + commitOid: safeObjectId(summary.commitOid), + parentOid: safeObjectId(summary.parentOid), + compareRef: boundedString(summary.compareRef, 240) ?? commitId.slice(0, 12), + baseRef: boundedString(summary.baseRef, 240) ?? 'parent', + changedFiles, + status: commitCompareStatus(summary.status), + entries: page.entries, + truncated: page.truncated || changedFiles > page.entries.length + }) +} + +/** One response carries the whole compare, so the entry list is bounded by both the entry cap and + * the bridge response budget rather than by a resumable offset the page would have to drive. */ +function compareEntryPage(result: unknown): { + entries: MobileWebSourceControlCompareEntry[] + reportedCount: number + truncated: boolean +} { + if (!isRecord(result) || !Array.isArray(result.entries)) { + throw new MobileWebBrokerError('host_error') + } + const entries: MobileWebSourceControlCompareEntry[] = [] + let retainedBytes = 0 + let droppedByBudget = false + for (const candidate of result.entries.slice(0, MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES)) { + const entry = compareEntry(candidate) + if (!entry) { + continue + } + const nextBytes = encodedByteLength(entry) + 1 + if ( + retainedBytes + nextBytes > + MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES - RESPONSE_BUDGET_RESERVE_BYTES + ) { + droppedByBudget = true + break + } + retainedBytes += nextBytes + entries.push(entry) + } + return { + entries, + reportedCount: result.entries.length, + truncated: droppedByBudget || entries.length < result.entries.length + } +} + +function compareEntry(candidate: unknown): MobileWebSourceControlCompareEntry | null { + if (!isRecord(candidate)) { + return null + } + const parsed = MobileWebSourceControlCompareEntrySchema.safeParse({ + relativePath: candidate.path, + ...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }), + status: candidate.status, + ...(optionalNonnegativeInteger(candidate.added) === undefined + ? {} + : { added: candidate.added }), + ...(optionalNonnegativeInteger(candidate.removed) === undefined + ? {} + : { removed: candidate.removed }) + }) + return parsed.success ? parsed.data : null +} + +function compareSummary(result: unknown): Record { + if (!isRecord(result) || !isRecord(result.summary)) { + throw new MobileWebBrokerError('host_error') + } + return result.summary +} + +function branchCompareStatus( + value: unknown +): 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'error' { + return value === 'ready' || + value === 'invalid-base' || + value === 'unborn-head' || + value === 'no-merge-base' + ? value + : 'error' +} + +function commitCompareStatus(value: unknown): 'ready' | 'invalid-commit' | 'error' { + return value === 'ready' || value === 'invalid-commit' ? value : 'error' +} + +function safeGitRef(value: unknown): string | null { + const parsed = MobileWebGitRefNameSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function safeObjectId(value: unknown): string | null { + const parsed = MobileWebGitObjectIdSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function safeNonnegativeInteger(value: unknown): number { + return optionalNonnegativeInteger(value) ?? 0 +} + +function optionalNonnegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function boundedString(value: unknown, limit: number): string | undefined { + return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined +} + +function encodedByteLength(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/shared/mobile-web/source-control-mutation-contract.test.ts b/src/shared/mobile-web/source-control-mutation-contract.test.ts index cfa890a66f4..a2f90c52307 100644 --- a/src/shared/mobile-web/source-control-mutation-contract.test.ts +++ b/src/shared/mobile-web/source-control-mutation-contract.test.ts @@ -1,70 +1,49 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT, - MobileWebSourceControlDiscardPayloadSchema, - MobileWebSourceControlStagePayloadSchema, - MobileWebSourceControlUnstagePayloadSchema + MobileWebSourceControlMutationPayloadSchema } from './source-control-mutation-contract' -const modified = { - relativePath: 'src/app.ts', - status: 'modified' as const, - area: 'unstaged' as const -} - describe('mobile web source-control mutation contract', () => { - it('accepts bounded stage and unstage snapshots', () => { + it('accepts a bounded set of workspace-relative paths', () => { expect( - MobileWebSourceControlStagePayloadSchema.parse({ + MobileWebSourceControlMutationPayloadSchema.parse({ workspaceId: 'workspace-1', - expectedHead: 'a'.repeat(40), - entries: [modified] + relativePaths: ['src/app.ts', 'src/other.ts'] }) - ).toMatchObject({ entries: [modified] }) - expect( - MobileWebSourceControlUnstagePayloadSchema.parse({ - workspaceId: 'workspace-1', - expectedHead: null, - entries: [{ ...modified, area: 'staged' }] - }) - ).toMatchObject({ entries: [{ area: 'staged' }] }) + ).toEqual({ workspaceId: 'workspace-1', relativePaths: ['src/app.ts', 'src/other.ts'] }) }) - it('requires explicit confirmation and eligible discard entries', () => { - expect(() => - MobileWebSourceControlDiscardPayloadSchema.parse({ + it('rejects duplicate, empty and oversized path sets', () => { + expect( + MobileWebSourceControlMutationPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: null, - entries: [modified] - }) - ).toThrow() - expect(() => - MobileWebSourceControlDiscardPayloadSchema.parse({ + relativePaths: ['src/app.ts', 'src/app.ts'] + }).success + ).toBe(false) + expect( + MobileWebSourceControlMutationPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: null, - confirmation: 'discard-confirmed', - entries: [{ ...modified, area: 'staged' }] - }) - ).toThrow() - }) - - it('rejects duplicate and oversized path sets', () => { - expect(() => - MobileWebSourceControlStagePayloadSchema.parse({ + relativePaths: [] + }).success + ).toBe(false) + expect( + MobileWebSourceControlMutationPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: null, - entries: [modified, modified] - }) - ).toThrow() - expect(() => - MobileWebSourceControlStagePayloadSchema.parse({ - workspaceId: 'workspace-1', - expectedHead: null, - entries: Array.from( + relativePaths: Array.from( { length: MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT + 1 }, - (_, index) => ({ ...modified, relativePath: `src/${index}.ts` }) + (_, index) => `src/${index}.ts` ) - }) - ).toThrow() + }).success + ).toBe(false) + }) + + it('rejects a path that escapes the workspace', () => { + expect( + MobileWebSourceControlMutationPayloadSchema.safeParse({ + workspaceId: 'workspace-1', + relativePaths: ['../outside.ts'] + }).success + ).toBe(false) }) }) diff --git a/src/shared/mobile-web/source-control-mutation-contract.ts b/src/shared/mobile-web/source-control-mutation-contract.ts index c3e2d8f1266..066aae6bf92 100644 --- a/src/shared/mobile-web/source-control-mutation-contract.ts +++ b/src/shared/mobile-web/source-control-mutation-contract.ts @@ -1,127 +1,27 @@ import { z } from 'zod' import { MobileWebWorkspaceIdSchema } from './bridge-operation-contract' -import { isMobileWebGitObjectId } from './protocol-token-contract' import { MobileWebSourceControlStatusEntrySchema } from './source-control-operation-contract' export const MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT = 32 export const MobileWebSourceControlMutationOperationSchema = z.enum(['stage', 'unstage', 'discard']) -export const MobileWebSourceControlMutationEntrySchema = - MobileWebSourceControlStatusEntrySchema.pick({ - relativePath: true, - oldRelativePath: true, - status: true, - area: true, - conflictStatus: true - }) - -const ExpectedHeadSchema = z.string().refine(isMobileWebGitObjectId).nullable() - -const MutationPayloadShape = { - workspaceId: MobileWebWorkspaceIdSchema, - expectedHead: ExpectedHeadSchema, - entries: z - .array(MobileWebSourceControlMutationEntrySchema) - .min(1) - .max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT) -} as const - -export const MobileWebSourceControlStagePayloadSchema = z - .object(MutationPayloadShape) - .strict() - .superRefine((payload, context) => { - validateUniquePaths(payload.entries, context) - payload.entries.forEach((entry, index) => { - if (entry.area === 'staged' || entry.conflictStatus === 'unresolved') { - context.addIssue({ - code: 'custom', - message: 'Entry cannot be staged', - path: ['entries', index] - }) - } - }) - }) - -export const MobileWebSourceControlUnstagePayloadSchema = z - .object(MutationPayloadShape) - .strict() - .superRefine((payload, context) => { - validateUniquePaths(payload.entries, context) - payload.entries.forEach((entry, index) => { - if (entry.area !== 'staged') { - context.addIssue({ - code: 'custom', - message: 'Entry cannot be unstaged', - path: ['entries', index] - }) - } - }) - }) - -export const MobileWebSourceControlDiscardPayloadSchema = z - .object({ - ...MutationPayloadShape, - confirmation: z.literal('discard-confirmed') - }) - .strict() - .superRefine((payload, context) => { - validateUniquePaths(payload.entries, context) - payload.entries.forEach((entry, index) => { - if (entry.area === 'staged' || entry.conflictStatus === 'unresolved') { - context.addIssue({ - code: 'custom', - message: 'Entry cannot be discarded', - path: ['entries', index] - }) - } - }) - }) - -export const MobileWebSourceControlMutationResultSchema = z +/** The Desktop decides whether a path may be staged, unstaged or discarded; the page sends the + * paths it saw and reads the refusal back from the Desktop. */ +export const MobileWebSourceControlMutationPayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema, - operation: MobileWebSourceControlMutationOperationSchema, relativePaths: z .array(MobileWebSourceControlStatusEntrySchema.shape.relativePath) .min(1) - .max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT), - mutated: z.literal(true) + .max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT) + .refine((paths) => new Set(paths).size === paths.length, 'Duplicate mutation path') }) .strict() export type MobileWebSourceControlMutationOperation = z.infer< typeof MobileWebSourceControlMutationOperationSchema > -export type MobileWebSourceControlMutationEntry = z.infer< - typeof MobileWebSourceControlMutationEntrySchema +export type MobileWebSourceControlMutationPayload = z.infer< + typeof MobileWebSourceControlMutationPayloadSchema > -export type MobileWebSourceControlStagePayload = z.infer< - typeof MobileWebSourceControlStagePayloadSchema -> -export type MobileWebSourceControlUnstagePayload = z.infer< - typeof MobileWebSourceControlUnstagePayloadSchema -> -export type MobileWebSourceControlDiscardPayload = z.infer< - typeof MobileWebSourceControlDiscardPayloadSchema -> -export type MobileWebSourceControlMutationResult = z.infer< - typeof MobileWebSourceControlMutationResultSchema -> - -function validateUniquePaths( - entries: readonly { relativePath: string }[], - context: z.RefinementCtx -): void { - const paths = new Set() - entries.forEach((entry, index) => { - if (paths.has(entry.relativePath)) { - context.addIssue({ - code: 'custom', - message: 'Duplicate mutation path', - path: ['entries', index, 'relativePath'] - }) - } - paths.add(entry.relativePath) - }) -} diff --git a/src/shared/mobile-web/source-control-repository-presentation.ts b/src/shared/mobile-web/source-control-repository-presentation.ts new file mode 100644 index 00000000000..e099dc43d5c --- /dev/null +++ b/src/shared/mobile-web/source-control-repository-presentation.ts @@ -0,0 +1,71 @@ +import { MobileWebGitRefNameSchema } from './source-control-history-contract' +import { + MobileWebSourceControlRepositoryStateSchema, + MobileWebSourceControlUpstreamSnapshotSchema, + type MobileWebSourceControlRepositoryState, + type MobileWebSourceControlUpstreamSnapshot +} from './source-control-sync-contract' +import { MobileWebBrokerError } from './bridge-operation-error' + +export function projectMobileWebRepositoryState(args: { + status: unknown + upstream: unknown + baseRef: unknown + workspaceId: string +}): MobileWebSourceControlRepositoryState { + if (!isRecord(args.status)) { + throw new MobileWebBrokerError('host_error') + } + return MobileWebSourceControlRepositoryStateSchema.parse({ + workspaceId: args.workspaceId, + head: safeHead(args.status.head), + branch: safeBranch(args.status.branch), + conflictOperation: safeConflictOperation(args.status.conflictOperation), + baseRef: safeBranch(args.baseRef), + upstream: projectMobileWebUpstreamSnapshot(args.upstream) + }) +} + +export function projectMobileWebUpstreamSnapshot( + value: unknown +): MobileWebSourceControlUpstreamSnapshot { + if (!isRecord(value)) { + throw new MobileWebBrokerError('host_error') + } + const upstreamName = boundedString(value.upstreamName, 240) + return MobileWebSourceControlUpstreamSnapshotSchema.parse({ + hasUpstream: value.hasUpstream === true, + ...(upstreamName ? { upstreamName } : {}), + ahead: safeNonnegativeInteger(value.ahead), + behind: safeNonnegativeInteger(value.behind), + hasConfiguredPushTarget: value.hasConfiguredPushTarget === true, + behindCommitsArePatchEquivalent: value.behindCommitsArePatchEquivalent === true + }) +} + +function safeHead(value: unknown): string | null { + return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) ? value : null +} + +function safeBranch(value: unknown): string | null { + const parsed = MobileWebGitRefNameSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function safeConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' { + return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown' +} + +function safeNonnegativeInteger(value: unknown): number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0 +} + +function boundedString(value: unknown, limit: number): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim().slice(0, limit) + : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/shared/mobile-web/source-control-review-contract.ts b/src/shared/mobile-web/source-control-review-contract.ts index 5fedd17f17c..7d546c816f0 100644 --- a/src/shared/mobile-web/source-control-review-contract.ts +++ b/src/shared/mobile-web/source-control-review-contract.ts @@ -111,12 +111,17 @@ export const MobileWebSourceControlReviewMetadataResultSchema = z .strict() .superRefine(rejectDuplicateReviewMetadataKeys) +/** Shared with the Desktop wrapper, which addresses the workspace by worktree selector instead. */ +export const MobileWebSourceControlReviewMetadataUpdateShape = { + expectedRevision: RevisionSchema, + comments: ReviewMetadataShape.comments, + reviewState: ReviewMetadataShape.reviewState +} as const + export const MobileWebSourceControlReviewMetadataUpdatePayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema, - expectedRevision: RevisionSchema, - comments: ReviewMetadataShape.comments, - reviewState: ReviewMetadataShape.reviewState + ...MobileWebSourceControlReviewMetadataUpdateShape }) .strict() .superRefine(rejectDuplicateReviewMetadataKeys) @@ -130,32 +135,41 @@ export const MobileWebSourceControlReviewCompareSchema = z }) .strict() +export const MobileWebSourceControlReviewDiffShape = { + relativePath: MobileWebRelativePathSchema, + oldRelativePath: MobileWebRelativePathSchema.optional(), + scope: ReviewScopeSchema, + compare: MobileWebSourceControlReviewCompareSchema.optional(), + offset: z.number().int().min(0).max(MOBILE_WEB_DIFF_MAX_ROWS).default(0), + limit: z + .number() + .int() + .min(1) + .max(MOBILE_WEB_DIFF_PAGE_LIMIT) + .default(MOBILE_WEB_DIFF_PAGE_LIMIT), + expectedRevision: RevisionSchema.optional() +} as const + +export function rejectMissingReviewCompareIdentity( + value: { scope: string; compare?: unknown }, + context: z.RefinementCtx +): void { + if ((value.scope === 'branch') !== Boolean(value.compare)) { + context.addIssue({ + code: 'custom', + path: ['compare'], + message: 'Branch diffs require exact compare identity' + }) + } +} + export const MobileWebSourceControlReviewDiffPayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema, - relativePath: MobileWebRelativePathSchema, - oldRelativePath: MobileWebRelativePathSchema.optional(), - scope: ReviewScopeSchema, - compare: MobileWebSourceControlReviewCompareSchema.optional(), - offset: z.number().int().min(0).max(MOBILE_WEB_DIFF_MAX_ROWS).default(0), - limit: z - .number() - .int() - .min(1) - .max(MOBILE_WEB_DIFF_PAGE_LIMIT) - .default(MOBILE_WEB_DIFF_PAGE_LIMIT), - expectedRevision: RevisionSchema.optional() + ...MobileWebSourceControlReviewDiffShape }) .strict() - .superRefine((value, context) => { - if ((value.scope === 'branch') !== Boolean(value.compare)) { - context.addIssue({ - code: 'custom', - path: ['compare'], - message: 'Branch diffs require exact compare identity' - }) - } - }) + .superRefine(rejectMissingReviewCompareIdentity) const ReviewDiffIdentityShape = { workspaceId: MobileWebWorkspaceIdSchema, @@ -248,7 +262,7 @@ export type MobileWebSourceControlReviewTerminalSendResult = z.infer< typeof MobileWebSourceControlReviewTerminalSendResultSchema > -function rejectDuplicateReviewMetadataKeys( +export function rejectDuplicateReviewMetadataKeys( value: { comments: { id: string }[] reviewState: { files: { key: string }[] } diff --git a/src/shared/mobile-web/source-control-review-presentation.ts b/src/shared/mobile-web/source-control-review-presentation.ts new file mode 100644 index 00000000000..71c4fd89f06 --- /dev/null +++ b/src/shared/mobile-web/source-control-review-presentation.ts @@ -0,0 +1,212 @@ +import { sha256 } from '../sha256' +import { + MOBILE_WEB_REVIEW_COMMENT_LIMIT, + MOBILE_WEB_REVIEW_FILE_STATE_LIMIT, + MobileWebSourceControlReviewCommentSchema, + MobileWebSourceControlReviewFileStateSchema, + MobileWebSourceControlReviewLinkResultSchema, + MobileWebSourceControlReviewMetadataResultSchema, + type MobileWebSourceControlReviewComment, + type MobileWebSourceControlReviewLinkResult, + type MobileWebSourceControlReviewMetadataResult, + type MobileWebSourceControlReviewState +} from './source-control-review-contract' +import { MobileWebBrokerError } from './bridge-operation-error' + +export function projectMobileWebReviewMetadata( + worktree: unknown, + workspaceId: string +): MobileWebSourceControlReviewMetadataResult { + if (!isRecord(worktree)) { + throw new MobileWebBrokerError('host_error') + } + const rawComments = Array.isArray(worktree.diffComments) ? worktree.diffComments : [] + const rawReview = isRecord(worktree.mobileDiffReview) ? worktree.mobileDiffReview : {} + const rawFiles = isRecord(rawReview.files) ? Object.values(rawReview.files) : [] + if ( + rawComments.length > MOBILE_WEB_REVIEW_COMMENT_LIMIT || + rawFiles.length > MOBILE_WEB_REVIEW_FILE_STATE_LIMIT + ) { + throw new MobileWebBrokerError('too_large') + } + const comments = rawComments.map(projectComment) + const reviewState: MobileWebSourceControlReviewState = { + version: 1, + ...(safeTimestamp(rawReview.updatedAt) === undefined + ? {} + : { updatedAt: safeTimestamp(rawReview.updatedAt) }), + ...(safeTimestamp(rawReview.completedAt) === undefined + ? {} + : { completedAt: safeTimestamp(rawReview.completedAt) }), + files: rawFiles.map(projectFileState) + } + return MobileWebSourceControlReviewMetadataResultSchema.parse({ + workspaceId, + revision: mobileWebReviewMetadataRevision({ comments, reviewState }), + comments, + reviewState + }) +} + +export function mobileWebReviewMetadataRevision(value: unknown): string { + return Array.from(sha256(new TextEncoder().encode(JSON.stringify(value))), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') +} + +/** The only worktree fields a review write may touch. Everything else on the record stays out of + * reach of the page. */ +export function mobileWebReviewMetadataWorktreeFields(args: { + worktreeId: string + comments: readonly MobileWebSourceControlReviewComment[] + reviewState: MobileWebSourceControlReviewState +}) { + return { + diffComments: args.comments.map((comment) => ({ + id: comment.id, + worktreeId: args.worktreeId, + filePath: comment.relativePath, + ...(comment.oldRelativePath ? { oldPath: comment.oldRelativePath } : {}), + ...(comment.source ? { source: comment.source } : {}), + ...(comment.selectedText === undefined ? {} : { selectedText: comment.selectedText }), + ...(comment.startLine === undefined ? {} : { startLine: comment.startLine }), + lineNumber: comment.lineNumber, + body: comment.body, + createdAt: comment.createdAt, + ...(comment.updatedAt === undefined ? {} : { updatedAt: comment.updatedAt }), + ...(comment.sentAt === undefined ? {} : { sentAt: comment.sentAt }), + ...(comment.scope ? { scope: comment.scope } : {}), + ...(comment.diffIdentity ? { diffIdentity: comment.diffIdentity } : {}), + side: 'modified' + })), + mobileDiffReview: { + version: 1, + ...(args.reviewState.updatedAt === undefined + ? {} + : { updatedAt: args.reviewState.updatedAt }), + ...(args.reviewState.completedAt === undefined + ? {} + : { completedAt: args.reviewState.completedAt }), + files: Object.fromEntries( + args.reviewState.files.map((file) => [ + file.key, + { + key: file.key, + filePath: file.relativePath, + ...(file.oldRelativePath ? { oldPath: file.oldRelativePath } : {}), + scope: file.scope, + ...(file.lastOpenedAt === undefined ? {} : { lastOpenedAt: file.lastOpenedAt }), + ...(file.lastSeenDiffIdentity + ? { lastSeenDiffIdentity: file.lastSeenDiffIdentity } + : {}), + ...(file.reviewedAt === undefined ? {} : { reviewedAt: file.reviewedAt }), + ...(file.reviewDiffIdentity ? { reviewDiffIdentity: file.reviewDiffIdentity } : {}) + } + ]) + ) + } + } +} + +export function projectMobileWebReviewLink( + worktree: unknown, + workspaceId: string +): MobileWebSourceControlReviewLinkResult { + if (!isRecord(worktree)) { + throw new MobileWebBrokerError('host_error') + } + return MobileWebSourceControlReviewLinkResultSchema.parse({ + workspaceId, + baseRef: boundedText(worktree.baseRef, 512), + linkedGitHubPR: positiveInteger(worktree.linkedPR), + linkedGitLabMR: positiveInteger(worktree.linkedGitLabMR), + linkedBitbucketPR: positiveInteger(worktree.linkedBitbucketPR), + linkedAzureDevOpsPR: positiveInteger(worktree.linkedAzureDevOpsPR), + linkedGiteaPR: positiveInteger(worktree.linkedGiteaPR) + }) +} + +export function mobileWebReviewLinkWorktreeField( + provider: string, + number: number | null +): Record { + if (provider === 'github') { + return { linkedPR: number } + } + if (provider === 'gitlab') { + return { linkedGitLabMR: number } + } + if (provider === 'bitbucket') { + return { linkedBitbucketPR: number } + } + if (provider === 'azure-devops') { + return { linkedAzureDevOpsPR: number } + } + return { linkedGiteaPR: number } +} + +function projectComment(value: unknown): MobileWebSourceControlReviewComment { + if (!isRecord(value)) { + throw new MobileWebBrokerError('host_error') + } + const parsed = MobileWebSourceControlReviewCommentSchema.safeParse({ + id: value.id, + relativePath: value.filePath, + ...(value.oldPath === undefined ? {} : { oldRelativePath: value.oldPath }), + ...(value.source === undefined ? {} : { source: value.source }), + ...(value.selectedText === undefined ? {} : { selectedText: value.selectedText }), + ...(value.startLine === undefined ? {} : { startLine: value.startLine }), + lineNumber: value.lineNumber, + body: value.body, + createdAt: value.createdAt, + ...(value.updatedAt === undefined ? {} : { updatedAt: value.updatedAt }), + ...(value.sentAt === undefined ? {} : { sentAt: value.sentAt }), + ...(value.scope === undefined ? {} : { scope: value.scope }), + ...(value.diffIdentity === undefined ? {} : { diffIdentity: value.diffIdentity }), + side: 'modified' + }) + if (!parsed.success) { + throw new MobileWebBrokerError('host_error') + } + return parsed.data +} + +function projectFileState(value: unknown) { + if (!isRecord(value)) { + throw new MobileWebBrokerError('host_error') + } + const parsed = MobileWebSourceControlReviewFileStateSchema.safeParse({ + key: value.key, + relativePath: value.filePath, + ...(value.oldPath === undefined ? {} : { oldRelativePath: value.oldPath }), + scope: value.scope, + ...(value.lastOpenedAt === undefined ? {} : { lastOpenedAt: value.lastOpenedAt }), + ...(value.lastSeenDiffIdentity === undefined + ? {} + : { lastSeenDiffIdentity: value.lastSeenDiffIdentity }), + ...(value.reviewedAt === undefined ? {} : { reviewedAt: value.reviewedAt }), + ...(value.reviewDiffIdentity === undefined + ? {} + : { reviewDiffIdentity: value.reviewDiffIdentity }) + }) + if (!parsed.success) { + throw new MobileWebBrokerError('host_error') + } + return parsed.data +} + +function safeTimestamp(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function positiveInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null +} + +function boundedText(value: unknown, limit: number): string | null { + return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/shared/mobile-web/source-control-sync-contract.test.ts b/src/shared/mobile-web/source-control-sync-contract.test.ts index 585cb203a37..cf9bcdd591b 100644 --- a/src/shared/mobile-web/source-control-sync-contract.test.ts +++ b/src/shared/mobile-web/source-control-sync-contract.test.ts @@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest' import { MobileWebSourceControlCheckoutPayloadSchema, MobileWebSourceControlPushPayloadSchema, - MobileWebSourceControlRepositoryStateSchema, - MobileWebSourceControlSyncResultSchema + MobileWebSourceControlRepositoryStateSchema } from './source-control-sync-contract' const HEAD = 'a'.repeat(40) @@ -17,53 +16,33 @@ const upstream = { } describe('mobile web source-control sync contract', () => { - it('requires an explicit checkout confirmation and a non-option local ref', () => { - const identity = { - workspaceId: 'workspace-1', - expectedHead: HEAD, - expectedBranch: 'main' - } + it('requires a non-option local ref to check out', () => { expect( MobileWebSourceControlCheckoutPayloadSchema.safeParse({ - ...identity, - branch: 'feature/mobile', - confirmation: 'checkout-confirmed' + workspaceId: 'workspace-1', + branch: 'feature/mobile' }).success ).toBe(true) expect( MobileWebSourceControlCheckoutPayloadSchema.safeParse({ - ...identity, - branch: '--force', - confirmation: 'checkout-confirmed' - }).success - ).toBe(false) - expect( - MobileWebSourceControlCheckoutPayloadSchema.safeParse({ - ...identity, - branch: 'feature/mobile' + workspaceId: 'workspace-1', + branch: '--force' }).success ).toBe(false) }) - it('requires an exact bounded upstream snapshot and push confirmation', () => { + it('carries only the push mode the Desktop reauthorizes', () => { expect( MobileWebSourceControlPushPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: HEAD, - expectedBranch: 'main', - expectedUpstream: upstream, - mode: 'push', - confirmation: 'push-confirmed' + mode: 'publish' }).success ).toBe(true) expect( MobileWebSourceControlPushPayloadSchema.safeParse({ workspaceId: 'workspace-1', - expectedHead: HEAD, - expectedBranch: 'main', - expectedUpstream: { ...upstream, ahead: -1 }, mode: 'push', - confirmation: 'push-confirmed' + forceWithLease: true }).success ).toBe(false) }) @@ -88,7 +67,7 @@ describe('mobile web source-control sync contract', () => { } }) - it('keeps repository and action results strict and request-identifiable', () => { + it('keeps the repository state strict', () => { const repository = { workspaceId: 'workspace-1', head: HEAD, @@ -104,26 +83,5 @@ describe('mobile web source-control sync contract', () => { hostPath: '/private/repository' }).success ).toBe(false) - expect( - MobileWebSourceControlSyncResultSchema.safeParse({ - workspaceId: 'workspace-1', - operation: 'push', - previousHead: HEAD, - previousBranch: 'main', - repository, - completed: true - }).success - ).toBe(true) - expect( - MobileWebSourceControlSyncResultSchema.safeParse({ - workspaceId: 'workspace-1', - operation: 'push', - previousHead: HEAD, - previousBranch: 'main', - branch: 'feature/mobile', - repository, - completed: true - }).success - ).toBe(false) }) }) diff --git a/src/shared/mobile-web/source-control-sync-contract.ts b/src/shared/mobile-web/source-control-sync-contract.ts index 5378cb79379..3630f14d99d 100644 --- a/src/shared/mobile-web/source-control-sync-contract.ts +++ b/src/shared/mobile-web/source-control-sync-contract.ts @@ -43,110 +43,63 @@ export const MobileWebSourceControlRepositoryStateSchema = z }) .strict() -export const MobileWebSourceControlUpstreamPayloadSchema = z +export const MobileWebSourceControlRepositoryStatePayloadSchema = z .object({ workspaceId: MobileWebWorkspaceIdSchema }) .strict() -const ExpectedRepositoryShape = { - workspaceId: MobileWebWorkspaceIdSchema, - expectedHead: NullableGitObjectIdSchema, - expectedBranch: NullableGitRefNameSchema -} as const - -const ExpectedRemoteRepositoryShape = { - ...ExpectedRepositoryShape, - expectedUpstream: MobileWebSourceControlUpstreamSnapshotSchema -} as const - export const MobileWebSourceControlCheckoutPayloadSchema = z .object({ - ...ExpectedRepositoryShape, - branch: MobileWebGitRefNameSchema, - confirmation: z.literal('checkout-confirmed') + workspaceId: MobileWebWorkspaceIdSchema, + branch: MobileWebGitRefNameSchema }) .strict() -export const MobileWebSourceControlFetchPayloadSchema = z.object(ExpectedRepositoryShape).strict() +export const MobileWebSourceControlSyncPayloadSchema = z + .object({ workspaceId: MobileWebWorkspaceIdSchema }) + .strict() export const MobileWebSourceControlPullPayloadSchema = z .object({ - ...ExpectedRemoteRepositoryShape, - strategy: z.enum(['fast-forward', 'merge']), - confirmation: z.literal('pull-confirmed') + workspaceId: MobileWebWorkspaceIdSchema, + strategy: z.enum(['fast-forward', 'merge']) }) .strict() export const MobileWebSourceControlPushPayloadSchema = z .object({ - ...ExpectedRemoteRepositoryShape, - mode: z.enum(['push', 'publish']), - confirmation: z.literal('push-confirmed') + workspaceId: MobileWebWorkspaceIdSchema, + mode: z.enum(['push', 'publish']) }) .strict() export const MobileWebSourceControlRebasePayloadSchema = z .object({ - ...ExpectedRemoteRepositoryShape, - baseRef: MobileWebGitRefNameSchema, - confirmation: z.literal('rebase-confirmed') + workspaceId: MobileWebWorkspaceIdSchema, + baseRef: MobileWebGitRefNameSchema }) .strict() export const MobileWebSourceControlAbortPayloadSchema = z .object({ - ...ExpectedRepositoryShape, - conflictOperation: z.enum(['merge', 'rebase']), - confirmation: z.literal('abort-confirmed') + workspaceId: MobileWebWorkspaceIdSchema, + conflictOperation: z.enum(['merge', 'rebase']) }) .strict() -export const MobileWebSourceControlSyncOperationSchema = z.enum([ - 'branch', - 'fetch', - 'pull', - 'push', - 'rebase', - 'abort' -]) - -const MobileWebSourceControlSyncResultShape = { - workspaceId: MobileWebWorkspaceIdSchema, - previousHead: NullableGitObjectIdSchema, - previousBranch: NullableGitRefNameSchema, - repository: MobileWebSourceControlRepositoryStateSchema.nullable(), - completed: z.literal(true) -} as const - -export const MobileWebSourceControlSyncResultSchema = z.discriminatedUnion('operation', [ - z - .object({ - ...MobileWebSourceControlSyncResultShape, - operation: z.literal('branch'), - branch: MobileWebGitRefNameSchema - }) - .strict(), - z - .object({ - ...MobileWebSourceControlSyncResultShape, - operation: z.enum(['fetch', 'pull', 'push', 'rebase', 'abort']) - }) - .strict() -]) - export type MobileWebSourceControlUpstreamSnapshot = z.infer< typeof MobileWebSourceControlUpstreamSnapshotSchema > export type MobileWebSourceControlRepositoryState = z.infer< typeof MobileWebSourceControlRepositoryStateSchema > -export type MobileWebSourceControlUpstreamPayload = z.infer< - typeof MobileWebSourceControlUpstreamPayloadSchema +export type MobileWebSourceControlRepositoryStatePayload = z.infer< + typeof MobileWebSourceControlRepositoryStatePayloadSchema > export type MobileWebSourceControlCheckoutPayload = z.infer< typeof MobileWebSourceControlCheckoutPayloadSchema > -export type MobileWebSourceControlFetchPayload = z.infer< - typeof MobileWebSourceControlFetchPayloadSchema +export type MobileWebSourceControlSyncPayload = z.infer< + typeof MobileWebSourceControlSyncPayloadSchema > export type MobileWebSourceControlPullPayload = z.infer< typeof MobileWebSourceControlPullPayloadSchema @@ -160,9 +113,3 @@ export type MobileWebSourceControlRebasePayload = z.infer< export type MobileWebSourceControlAbortPayload = z.infer< typeof MobileWebSourceControlAbortPayloadSchema > -export type MobileWebSourceControlSyncOperation = z.infer< - typeof MobileWebSourceControlSyncOperationSchema -> -export type MobileWebSourceControlSyncResult = z.infer< - typeof MobileWebSourceControlSyncResultSchema ->