diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts index c6daf793276..3e1927989f9 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts @@ -32,6 +32,19 @@ afterAll(() => { }) describe('the mounted module loader', () => { + it('preserves constants through a star reexport in an evaluated module', () => { + const modules = loaderOver({ + 'constants.ts': `export const CAPABILITY = 'files.mutation-ownership.v1'`, + 'protocol.ts': `export * from './constants'`, + 'operation.ts': ` + import { CAPABILITY } from './protocol' + export const read = () => CAPABILITY + ` + }) + const { read } = modules.load<{ read: () => string }>('mobile/src/operation.ts') + expect(read()).toBe('files.mutation-ownership.v1') + }) + it('follows named reexports reached through the task foundation barrel', () => { const modules = loaderOver({ 'mobile-tasks-legacy-foundation.tsx': `export * from './mapping'`, diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.ts index 4f84012a073..31710e6678a 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.ts @@ -72,7 +72,18 @@ export function operationModuleLoader( if (file === sharedModulePath) { return deliveryAmbiguity } - return new Proxy({}, { get: (_target, key) => load(file)[String(key)] }) + return new Proxy( + {}, + { + get: (_target, key) => load(file)[String(key)], + // CommonJS star reexports enumerate the imported module's own exports. + ownKeys: () => Reflect.ownKeys(load(file)), + getOwnPropertyDescriptor: (_target, key) => { + const descriptor = Reflect.getOwnPropertyDescriptor(load(file), key) + return descriptor && { ...descriptor, configurable: true } + } + } + ) } function barrel(file: string, source: string): OperationModule { const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) diff --git a/src/main/ipc/jira-ipc-params.test.ts b/src/main/ipc/jira-ipc-params.test.ts new file mode 100644 index 00000000000..c793a234c66 --- /dev/null +++ b/src/main/ipc/jira-ipc-params.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + clampJiraIssueLimit, + normalizeJiraIssueUpdate, + normalizeJiraSiteId, + normalizeJiraSiteSelection +} from './jira-ipc-params' + +describe('Jira IPC parameters', () => { + it('preserves normalized sites and all-sites selection', () => { + expect(normalizeJiraSiteId(' site-1 ')).toBe('site-1') + expect(normalizeJiraSiteSelection(' all ')).toBe('all') + for (const value of [undefined, null, 1, {}, '', ' ']) { + expect(normalizeJiraSiteSelection(value)).toBeUndefined() + } + }) + + it('bounds issue reads and rejects nonfinite limits', () => { + expect(clampJiraIssueLimit(0)).toBe(1) + expect(clampJiraIssueLimit(1_000_000)).toBe(100) + expect(clampJiraIssueLimit(50)).toBe(50) + for (const value of [undefined, '100', Number.NaN, Infinity, -Infinity]) { + expect(clampJiraIssueLimit(value)).toBe(30) + } + }) + + it('preserves supported issue edits including clears and transition fields', () => { + const updates = { + title: '', + labels: [], + assigneeAccountId: null, + priorityId: null, + transitionId: '31', + transitionFields: { resolution: { id: '1' } }, + transitionComment: 'Verified' + } + expect(normalizeJiraIssueUpdate(updates)).toEqual(updates) + expect( + normalizeJiraIssueUpdate({ labelOperations: { add: ['new'], remove: ['old'] } }) + ).toEqual({ labelOperations: { add: ['new'], remove: ['old'] } }) + expect(normalizeJiraIssueUpdate({})).toEqual({}) + }) + + it.each([ + null, + [], + 'title', + { title: 1 }, + { labels: 'label' }, + { labels: ['label', 1] }, + { assigneeAccountId: {} }, + { priorityId: false }, + { transitionId: null }, + { transitionFields: [] }, + { transitionFields: null }, + { transitionComment: 1 }, + { labelOperations: null }, + { labelOperations: [] }, + { labelOperations: 1 }, + { labelOperations: { add: 'label' } }, + { labelOperations: { remove: [1] } } + ])('rejects malformed edits before they reach Jira: %j', (value) => { + expect(normalizeJiraIssueUpdate(value)).toBeNull() + }) +}) diff --git a/src/main/ipc/jira-ipc-params.ts b/src/main/ipc/jira-ipc-params.ts new file mode 100644 index 00000000000..d766455ae47 --- /dev/null +++ b/src/main/ipc/jira-ipc-params.ts @@ -0,0 +1,78 @@ +import type { JiraIssueUpdate, JiraSiteSelection } from '../../shared/jira-types' + +export function normalizeJiraSiteId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export function normalizeJiraSiteSelection(value: unknown): JiraSiteSelection | undefined { + const siteId = normalizeJiraSiteId(value) + return siteId +} + +export function clampJiraIssueLimit(value: unknown, fallback = 30): number { + const limit = typeof value === 'number' && Number.isFinite(value) ? value : fallback + return Math.min(Math.max(1, limit), 100) +} + +function normalizeStringArray(value: unknown): string[] | undefined { + if (value === undefined) { + return undefined + } + return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined +} + +/** Narrows an untrusted IPC payload to the issue-update fields the host accepts. */ +export function normalizeJiraIssueUpdate(value: unknown): JiraIssueUpdate | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The following checks reject every host-accepted field with an invalid value. + const input = value as JiraIssueUpdate + if (input.title !== undefined && typeof input.title !== 'string') { + return null + } + if (input.labels !== undefined && normalizeStringArray(input.labels) === undefined) { + return null + } + if (input.labelOperations !== undefined) { + const operations = input.labelOperations + if ( + !operations || + typeof operations !== 'object' || + Array.isArray(operations) || + (operations.add !== undefined && normalizeStringArray(operations.add) === undefined) || + (operations.remove !== undefined && normalizeStringArray(operations.remove) === undefined) + ) { + return null + } + } + if ( + input.assigneeAccountId !== undefined && + input.assigneeAccountId !== null && + typeof input.assigneeAccountId !== 'string' + ) { + return null + } + if ( + input.priorityId !== undefined && + input.priorityId !== null && + typeof input.priorityId !== 'string' + ) { + return null + } + if (input.transitionId !== undefined && typeof input.transitionId !== 'string') { + return null + } + if ( + input.transitionFields !== undefined && + (input.transitionFields === null || + typeof input.transitionFields !== 'object' || + Array.isArray(input.transitionFields)) + ) { + return null + } + if (input.transitionComment !== undefined && typeof input.transitionComment !== 'string') { + return null + } + return input +} diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 6aa42a40d64..47727598a61 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -6,6 +6,12 @@ import { _resetPreflightCache } from './preflight' import { JiraCancellableRequests } from './jira-cancellable-requests' import { registerJiraUserSearchHandlers } from './jira-user-search' import { registerJiraProjectMetadataHandlers } from './jira-project-metadata' +import { + clampJiraIssueLimit, + normalizeJiraIssueUpdate, + normalizeJiraSiteId, + normalizeJiraSiteSelection +} from './jira-ipc-params' import { addIssueComment, createIssue, @@ -31,70 +37,6 @@ const VALID_FILTERS = new Set(['assigned', 'reported', 'all', ' const issueSummaryRequests = new JiraCancellableRequests() const searchRequests = new JiraCancellableRequests() -function normalizeSiteId(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value.trim() : undefined -} - -function normalizeSiteSelection(value: unknown): JiraSiteSelection | undefined { - const siteId = normalizeSiteId(value) - return siteId as JiraSiteSelection | undefined -} - -function clampLimit(value: unknown, fallback = 30): number { - const limit = typeof value === 'number' && Number.isFinite(value) ? value : fallback - return Math.min(Math.max(1, limit), 100) -} - -function normalizeStringArray(value: unknown): string[] | undefined { - if (value === undefined) { - return undefined - } - return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined -} - -/** Narrows an untrusted IPC payload to the issue-update fields the host accepts. */ -function normalizeIssueUpdate(value: unknown): JiraIssueUpdate | null { - if (!value || typeof value !== 'object') { - return null - } - const input = value as JiraIssueUpdate - if (input.title !== undefined && typeof input.title !== 'string') { - return null - } - if (input.labels !== undefined && normalizeStringArray(input.labels) === undefined) { - return null - } - if ( - input.assigneeAccountId !== undefined && - input.assigneeAccountId !== null && - typeof input.assigneeAccountId !== 'string' - ) { - return null - } - if ( - input.priorityId !== undefined && - input.priorityId !== null && - typeof input.priorityId !== 'string' - ) { - return null - } - if (input.transitionId !== undefined && typeof input.transitionId !== 'string') { - return null - } - if ( - input.transitionFields !== undefined && - (input.transitionFields === null || - typeof input.transitionFields !== 'object' || - Array.isArray(input.transitionFields)) - ) { - return null - } - if (input.transitionComment !== undefined && typeof input.transitionComment !== 'string') { - return null - } - return input -} - /** Registers every `jira:*` IPC handler on the main process. */ export function registerJiraHandlers(): void { ipcMain.handle('jira:connect', async (_event, args: JiraConnectArgs) => { @@ -118,12 +60,12 @@ export function registerJiraHandlers(): void { }) ipcMain.handle('jira:disconnect', async (_event, args?: { siteId?: string }) => { - disconnect(normalizeSiteId(args?.siteId)) + disconnect(normalizeJiraSiteId(args?.siteId)) _resetPreflightCache() }) ipcMain.handle('jira:selectSite', async (_event, args: { siteId: JiraSiteSelection }) => { - const siteId = normalizeSiteSelection(args?.siteId) + const siteId = normalizeJiraSiteSelection(args?.siteId) if (!siteId) { return getStatus() } @@ -139,7 +81,7 @@ export function registerJiraHandlers(): void { }) ipcMain.handle('jira:testConnection', async (_event, args?: { siteId?: string }) => { - return testConnection(normalizeSiteId(args?.siteId)) + return testConnection(normalizeJiraSiteId(args?.siteId)) }) ipcMain.handle( @@ -152,7 +94,12 @@ export function registerJiraHandlers(): void { return [] } return searchRequests.run(args.requestId, (signal) => - searchIssues(args.jql, clampLimit(args.limit), normalizeSiteSelection(args.siteId), signal) + searchIssues( + args.jql, + clampJiraIssueLimit(args.limit), + normalizeJiraSiteSelection(args.siteId), + signal + ) ) } ) @@ -177,8 +124,8 @@ export function registerJiraHandlers(): void { : undefined return listIssues( filter, - clampLimit(args?.limit), - normalizeSiteSelection(args?.siteId), + clampJiraIssueLimit(args?.limit), + normalizeJiraSiteSelection(args?.siteId), false, args?.filters ? JiraIssueListFiltersParam.parse(args.filters) : undefined ) @@ -193,7 +140,7 @@ export function registerJiraHandlers(): void { } return getIssue( args.key.trim(), - normalizeSiteId(args.siteId), + normalizeJiraSiteId(args.siteId), false, args.mediaFormat === 'reference' ? 'reference' : undefined ) @@ -232,7 +179,7 @@ export function registerJiraHandlers(): void { return { ok: false, error: 'Title is required.' } } return createIssue({ - siteId: normalizeSiteId(args.siteId), + siteId: normalizeJiraSiteId(args.siteId), projectId: args.projectId.trim(), issueTypeId: args.issueTypeId.trim(), title: args.title.trim(), @@ -251,11 +198,11 @@ export function registerJiraHandlers(): void { if (typeof args?.key !== 'string' || !args.key.trim()) { return { ok: false, error: 'Issue key is required.' } } - const updates = normalizeIssueUpdate(args.updates) + const updates = normalizeJiraIssueUpdate(args.updates) if (!updates) { return { ok: false, error: 'Updates object is required.' } } - return updateIssue(args.key.trim(), updates, normalizeSiteId(args.siteId)) + return updateIssue(args.key.trim(), updates, normalizeJiraSiteId(args.siteId)) } ) @@ -268,7 +215,7 @@ export function registerJiraHandlers(): void { if (typeof args?.body !== 'string' || !args.body.trim()) { return { ok: false, error: 'Comment body is required.' } } - return addIssueComment(args.key.trim(), args.body.trim(), normalizeSiteId(args.siteId)) + return addIssueComment(args.key.trim(), args.body.trim(), normalizeJiraSiteId(args.siteId)) } ) @@ -280,14 +227,14 @@ export function registerJiraHandlers(): void { } return getIssueComments( args.key.trim(), - normalizeSiteId(args.siteId), + normalizeJiraSiteId(args.siteId), false, args.mediaFormat === 'reference' ? 'reference' : undefined ) } ) - registerJiraProjectMetadataHandlers(normalizeSiteId) + registerJiraProjectMetadataHandlers(normalizeJiraSiteId) registerJiraUserSearchHandlers() @@ -297,7 +244,11 @@ export function registerJiraHandlers(): void { if (typeof args?.key !== 'string' || !args.key.trim()) { return [] } - return listTransitions(args.key.trim(), normalizeSiteId(args.siteId), args.strict === true) + return listTransitions( + args.key.trim(), + normalizeJiraSiteId(args.siteId), + args.strict === true + ) } ) @@ -307,7 +258,7 @@ export function registerJiraHandlers(): void { if (typeof args?.projectKey !== 'string' || !args.projectKey.trim()) { return { statusIdsByColumn: [] } } - return getProjectStatusOrder(args.projectKey.trim(), normalizeSiteId(args.siteId)) + return getProjectStatusOrder(args.projectKey.trim(), normalizeJiraSiteId(args.siteId)) } ) } diff --git a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx new file mode 100644 index 00000000000..b793fef4e22 --- /dev/null +++ b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx @@ -0,0 +1,252 @@ +// @vitest-environment happy-dom + +import { + deferred, + flushPromises, + initialSource, + initialSourceContextKey, + jiraIssue, + otherIssue, + runtimeMocks, + saveTitle, + selectIssue, + selectSource, + store, + StoreBackedWorkspace +} from './__mocks__/jira-issue-workspace-edit-fixture' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { JiraIssue } from '../../../shared/jira-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' + +describe('JiraIssueWorkspace reopened issue edits', () => { + it('keeps a newer reopened-issue edit when an earlier save fails', async () => { + const oldSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValue(deferred<{ ok: true }>().promise) + runtimeMocks.jiraGetIssueForTarget.mockImplementation( + async (_settings: unknown, key: string) => + key === 'ENG-2' ? otherIssue : jiraIssue('Old title') + ) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('Old title'))) + await flushPromises() + saveTitle('First edit', 'Latest edit') + await flushPromises() + await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) + await flushPromises() + expect(store.patchJiraIssue).toHaveBeenLastCalledWith( + 'ENG-1', + expect.objectContaining({ title: 'Latest edit' }), + { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } + ) + expect(screen.getByDisplayValue('Latest edit')).toBeDefined() + }) + + it('keeps a newer reopened-issue edit when an earlier refresh returns late', async () => { + const oldSave = deferred<{ ok: true }>() + const oldRefresh = deferred() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValue(deferred<{ ok: true }>().promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockReturnValueOnce(oldRefresh.promise) + .mockImplementation(async (_settings: unknown, key: string) => + key === 'ENG-2' ? otherIssue : jiraIssue('Old title') + ) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => oldSave.resolve({ ok: true })) + await flushPromises() + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('Old title'))) + await flushPromises() + saveTitle('First edit', 'Latest edit') + await flushPromises() + await act(async () => oldRefresh.resolve(jiraIssue('First edit'))) + await flushPromises() + expect(store.patchJiraIssue).toHaveBeenLastCalledWith( + 'ENG-1', + expect.objectContaining({ title: 'Latest edit' }), + { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } + ) + expect(screen.getByDisplayValue('Latest edit')).toBeDefined() + }) + + it('restores confirmed values when both reopened title saves fail', async () => { + const oldSave = deferred<{ ok: false; error: string }>() + const newerSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValueOnce(newerSave.promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockResolvedValueOnce(otherIssue) + .mockReturnValue(deferred().promise) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('First edit'))) + await flushPromises() + saveTitle('First edit', 'Latest edit') + await flushPromises() + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1) + await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) + await flushPromises() + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) + expect(screen.getByDisplayValue('Latest edit')).toBeDefined() + await act(async () => newerSave.resolve({ ok: false, error: 'Second save rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('Old title')).toBeDefined() + expect(store.patchJiraIssue).toHaveBeenLastCalledWith( + 'ENG-1', + { title: 'Old title' }, + { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } + ) + }) + + it('rolls back a failed title while preserving a newer edit to another field', async () => { + const oldSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValue(deferred<{ ok: true }>().promise) + const high = { id: '1', name: 'High' } + const low = { id: '2', name: 'Low' } + runtimeMocks.jiraListPrioritiesForTarget.mockResolvedValue([high, low]) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title', { priority: high })) + .mockResolvedValueOnce(otherIssue) + .mockReturnValue(deferred().promise) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('First edit', { priority: high }))) + await flushPromises() + fireEvent.click(screen.getByRole('button', { name: 'High' })) + fireEvent.click(await screen.findByRole('button', { name: 'Low' })) + await flushPromises() + await act(async () => oldSave.resolve({ ok: false, error: 'Title rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('Old title')).toBeDefined() + expect( + screen + .getAllByRole('button', { name: 'Low' }) + .some((button) => button.hasAttribute('disabled')) + ).toBe(true) + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenLastCalledWith( + { kind: 'local' }, + 'ENG-1', + { priorityId: '2' }, + 'site-1' + ) + }) + + it('preserves mutation order when the workspace unmounts and remounts', async () => { + const oldSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValue(deferred<{ ok: true }>().promise) + const mounted = render() + await flushPromises() + saveTitle('Old title', 'First edit') + mounted.unmount() + render() + await flushPromises() + saveTitle('First edit', 'Latest edit') + await flushPromises() + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1) + await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) + await flushPromises() + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) + expect(screen.getByDisplayValue('Latest edit')).toBeDefined() + expect(store.patchJiraIssue).toHaveBeenLastCalledWith( + 'ENG-1', + { title: 'Latest edit' }, + { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } + ) + }) + + it.each(['account', 'host', 'site'] as const)( + 'keeps saves independent when the same issue key moves to another %s', + async (kind) => { + runtimeMocks.jiraGetIssueForTarget.mockImplementation( + async (_owner: RuntimeClientTarget, _key: string, siteId?: string | null) => + jiraIssue('Old title', { siteId: siteId ?? undefined }) + ) + const oldSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValue(deferred<{ ok: true }>().promise) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => { + if (kind === 'site') { + selectIssue(jiraIssue('Old title', { siteId: 'site-2' })) + } else { + selectSource({ + ...initialSource, + ...(kind === 'account' + ? { accountLabel: 'other-account' } + : { hostId: 'runtime:other-host' }) + }) + } + }) + await flushPromises() + saveTitle('Old title', 'Latest edit') + await flushPromises() + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) + expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenLastCalledWith( + kind === 'host' ? { kind: 'environment', environmentId: 'other-host' } : { kind: 'local' }, + 'ENG-1', + { title: 'Latest edit' }, + kind === 'site' ? 'site-2' : 'site-1' + ) + await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('Latest edit')).toBeDefined() + expect(store.patchJiraIssue).toHaveBeenLastCalledWith( + 'ENG-1', + { title: 'Old title' }, + { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } + ) + } + ) + + it('preserves a replayed save across reopened hydration and synchronizes the title draft', async () => { + const oldSave = deferred<{ ok: true }>() + const reopenedLoad = deferred() + runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(oldSave.promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockResolvedValueOnce(otherIssue) + .mockReturnValueOnce(reopenedLoad.promise) + .mockResolvedValue(jiraIssue('First edit')) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('Old title'))) + await flushPromises() + expect(screen.getByDisplayValue('First edit')).toBeDefined() + await act(async () => oldSave.resolve({ ok: true })) + await flushPromises() + await act(async () => reopenedLoad.resolve(jiraIssue('Old title'))) + await flushPromises() + expect(screen.getByDisplayValue('First edit')).toBeDefined() + expect(screen.queryByDisplayValue('Old title')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-rollback.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-rollback.test.tsx new file mode 100644 index 00000000000..4ac0a9b7009 --- /dev/null +++ b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-rollback.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { + deferred, + flushPromises, + jiraIssue, + otherIssue, + runtimeMocks, + saveTitle, + selectIssue, + StoreBackedWorkspace +} from './__mocks__/jira-issue-workspace-edit-fixture' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { JiraIssue } from '../../../shared/jira-types' + +describe('JiraIssueWorkspace rollback baselines', () => { + it.each([ + ['succeeds', { ok: true }], + ['fails', { ok: false, error: 'Title rejected' }] + ] as const)( + 'preserves an unsaved title draft when an older reopened save %s', + async (_, outcome) => { + const oldSave = deferred() + runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(oldSave.promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockResolvedValueOnce(otherIssue) + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockResolvedValue(jiraIssue('First edit')) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('Old title'))) + await flushPromises() + fireEvent.change(screen.getByDisplayValue('First edit'), { + target: { value: 'Unsubmitted draft' } + }) + await act(async () => oldSave.resolve(outcome)) + await flushPromises() + expect(screen.getByDisplayValue('Unsubmitted draft')).toBeDefined() + expect( + screen.getAllByRole('heading', { name: outcome.ok ? 'First edit' : 'Old title' }).length + ).toBeGreaterThan(0) + } + ) + + it('uses newly hydrated values as the rollback baseline for a different field', async () => { + const oldSave = deferred<{ ok: false; error: string }>() + const newerSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValueOnce(newerSave.promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockResolvedValueOnce(otherIssue) + .mockResolvedValue(jiraIssue('Old title', { labels: ['backend'] })) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('First edit'))) + await flushPromises() + const labels = screen.getByDisplayValue('backend') + fireEvent.change(labels, { target: { value: 'new' } }) + const save = labels.parentElement?.querySelector('button') + if (!(save instanceof HTMLButtonElement)) { + throw new Error('Missing labels save button') + } + fireEvent.click(save) + await flushPromises() + await act(async () => oldSave.resolve({ ok: false, error: 'Title rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('new')).toBeDefined() + await act(async () => newerSave.resolve({ ok: false, error: 'Labels rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('Old title')).toBeDefined() + expect(screen.getByDisplayValue('backend')).toBeDefined() + }) + + it('keeps a newer hydrated rollback baseline when an older refresh returns stale fields', async () => { + const oldSave = deferred<{ ok: true }>() + const oldRefresh = deferred() + const newerSave = deferred<{ ok: false; error: string }>() + runtimeMocks.jiraUpdateIssueForTarget + .mockReturnValueOnce(oldSave.promise) + .mockReturnValueOnce(newerSave.promise) + runtimeMocks.jiraGetIssueForTarget + .mockResolvedValueOnce(jiraIssue('Old title')) + .mockReturnValueOnce(oldRefresh.promise) + .mockResolvedValueOnce(otherIssue) + .mockResolvedValue(jiraIssue('First edit', { labels: ['backend'] })) + render() + await flushPromises() + saveTitle('Old title', 'First edit') + await act(async () => oldSave.resolve({ ok: true })) + await flushPromises() + await act(async () => selectIssue(otherIssue)) + await flushPromises() + await act(async () => selectIssue(jiraIssue('First edit'))) + await flushPromises() + const labels = screen.getByDisplayValue('backend') + fireEvent.change(labels, { target: { value: 'new' } }) + const save = labels.parentElement?.querySelector('button') + if (!(save instanceof HTMLButtonElement)) { + throw new Error('Missing labels save button') + } + fireEvent.click(save) + await flushPromises() + await act(async () => oldRefresh.resolve(jiraIssue('First edit'))) + await flushPromises() + await act(async () => newerSave.resolve({ ok: false, error: 'Labels rejected' })) + await flushPromises() + expect(screen.getByDisplayValue('backend')).toBeDefined() + }) +}) diff --git a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx index 138b6159f3d..6f44194961f 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx @@ -5,7 +5,6 @@ import { editState, flushPromises, initialSource, - initialSourceContextKey, jiraIssue, otherIssue, runtimeMocks, @@ -295,336 +294,4 @@ describe('JiraIssueWorkspace optimistic edits', () => { expect(screen.getByDisplayValue('New title')).toBeDefined() }) - - it('keeps a newer reopened-issue edit when an earlier save fails', async () => { - const oldSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValue(deferred<{ ok: true }>().promise) - runtimeMocks.jiraGetIssueForTarget.mockImplementation( - async (_settings: unknown, key: string) => - key === 'ENG-2' ? otherIssue : jiraIssue('Old title') - ) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('Old title'))) - await flushPromises() - saveTitle('First edit', 'Latest edit') - await flushPromises() - await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) - await flushPromises() - expect(store.patchJiraIssue).toHaveBeenLastCalledWith( - 'ENG-1', - expect.objectContaining({ title: 'Latest edit' }), - { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } - ) - expect(screen.getByDisplayValue('Latest edit')).toBeDefined() - }) - - it('keeps a newer reopened-issue edit when an earlier refresh returns late', async () => { - const oldSave = deferred<{ ok: true }>() - const oldRefresh = deferred() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValue(deferred<{ ok: true }>().promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockReturnValueOnce(oldRefresh.promise) - .mockImplementation(async (_settings: unknown, key: string) => - key === 'ENG-2' ? otherIssue : jiraIssue('Old title') - ) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => oldSave.resolve({ ok: true })) - await flushPromises() - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('Old title'))) - await flushPromises() - saveTitle('First edit', 'Latest edit') - await flushPromises() - await act(async () => oldRefresh.resolve(jiraIssue('First edit'))) - await flushPromises() - expect(store.patchJiraIssue).toHaveBeenLastCalledWith( - 'ENG-1', - expect.objectContaining({ title: 'Latest edit' }), - { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } - ) - expect(screen.getByDisplayValue('Latest edit')).toBeDefined() - }) - - it('restores confirmed values when both reopened title saves fail', async () => { - const oldSave = deferred<{ ok: false; error: string }>() - const newerSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValueOnce(newerSave.promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockResolvedValueOnce(otherIssue) - .mockReturnValue(deferred().promise) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('First edit'))) - await flushPromises() - saveTitle('First edit', 'Latest edit') - await flushPromises() - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1) - await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) - await flushPromises() - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) - expect(screen.getByDisplayValue('Latest edit')).toBeDefined() - await act(async () => newerSave.resolve({ ok: false, error: 'Second save rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('Old title')).toBeDefined() - expect(store.patchJiraIssue).toHaveBeenLastCalledWith( - 'ENG-1', - { title: 'Old title' }, - { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } - ) - }) - - it('rolls back a failed title while preserving a newer edit to another field', async () => { - const oldSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValue(deferred<{ ok: true }>().promise) - const high = { id: '1', name: 'High' } - const low = { id: '2', name: 'Low' } - runtimeMocks.jiraListPrioritiesForTarget.mockResolvedValue([high, low]) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title', { priority: high })) - .mockResolvedValueOnce(otherIssue) - .mockReturnValue(deferred().promise) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('First edit', { priority: high }))) - await flushPromises() - fireEvent.click(screen.getByRole('button', { name: 'High' })) - fireEvent.click(await screen.findByRole('button', { name: 'Low' })) - await flushPromises() - await act(async () => oldSave.resolve({ ok: false, error: 'Title rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('Old title')).toBeDefined() - expect( - screen - .getAllByRole('button', { name: 'Low' }) - .some((button) => button.hasAttribute('disabled')) - ).toBe(true) - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenLastCalledWith( - { kind: 'local' }, - 'ENG-1', - { priorityId: '2' }, - 'site-1' - ) - }) - - it('preserves mutation order when the workspace unmounts and remounts', async () => { - const oldSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValue(deferred<{ ok: true }>().promise) - const mounted = render() - await flushPromises() - saveTitle('Old title', 'First edit') - mounted.unmount() - render() - await flushPromises() - saveTitle('First edit', 'Latest edit') - await flushPromises() - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1) - await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) - await flushPromises() - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) - expect(screen.getByDisplayValue('Latest edit')).toBeDefined() - expect(store.patchJiraIssue).toHaveBeenLastCalledWith( - 'ENG-1', - { title: 'Latest edit' }, - { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } - ) - }) - - it.each(['account', 'host', 'site'] as const)( - 'keeps saves independent when the same issue key moves to another %s', - async (kind) => { - runtimeMocks.jiraGetIssueForTarget.mockImplementation( - async (_owner: RuntimeClientTarget, _key: string, siteId?: string | null) => - jiraIssue('Old title', { siteId: siteId ?? undefined }) - ) - const oldSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValue(deferred<{ ok: true }>().promise) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => { - if (kind === 'site') { - selectIssue(jiraIssue('Old title', { siteId: 'site-2' })) - } else { - selectSource({ - ...initialSource, - ...(kind === 'account' - ? { accountLabel: 'other-account' } - : { hostId: 'runtime:other-host' }) - }) - } - }) - await flushPromises() - saveTitle('Old title', 'Latest edit') - await flushPromises() - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(2) - expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenLastCalledWith( - kind === 'host' ? { kind: 'environment', environmentId: 'other-host' } : { kind: 'local' }, - 'ENG-1', - { title: 'Latest edit' }, - kind === 'site' ? 'site-2' : 'site-1' - ) - await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('Latest edit')).toBeDefined() - expect(store.patchJiraIssue).toHaveBeenLastCalledWith( - 'ENG-1', - { title: 'Old title' }, - { sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey } - ) - } - ) - - it('preserves a replayed save across reopened hydration and synchronizes the title draft', async () => { - const oldSave = deferred<{ ok: true }>() - const reopenedLoad = deferred() - runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(oldSave.promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockResolvedValueOnce(otherIssue) - .mockReturnValueOnce(reopenedLoad.promise) - .mockResolvedValue(jiraIssue('First edit')) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('Old title'))) - await flushPromises() - expect(screen.getByDisplayValue('First edit')).toBeDefined() - await act(async () => oldSave.resolve({ ok: true })) - await flushPromises() - await act(async () => reopenedLoad.resolve(jiraIssue('Old title'))) - await flushPromises() - expect(screen.getByDisplayValue('First edit')).toBeDefined() - expect(screen.queryByDisplayValue('Old title')).toBeNull() - }) - - it.each([ - ['succeeds', { ok: true }], - ['fails', { ok: false, error: 'Title rejected' }] - ] as const)( - 'preserves an unsaved title draft when an older reopened save %s', - async (_, outcome) => { - const oldSave = deferred() - runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(oldSave.promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockResolvedValueOnce(otherIssue) - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockResolvedValue(jiraIssue('First edit')) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('Old title'))) - await flushPromises() - fireEvent.change(screen.getByDisplayValue('First edit'), { - target: { value: 'Unsubmitted draft' } - }) - await act(async () => oldSave.resolve(outcome)) - await flushPromises() - expect(screen.getByDisplayValue('Unsubmitted draft')).toBeDefined() - expect( - screen.getAllByRole('heading', { name: outcome.ok ? 'First edit' : 'Old title' }).length - ).toBeGreaterThan(0) - } - ) - - it('uses newly hydrated values as the rollback baseline for a different field', async () => { - const oldSave = deferred<{ ok: false; error: string }>() - const newerSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValueOnce(newerSave.promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockResolvedValueOnce(otherIssue) - .mockResolvedValue(jiraIssue('Old title', { labels: ['backend'] })) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('First edit'))) - await flushPromises() - const labels = screen.getByDisplayValue('backend') - fireEvent.change(labels, { target: { value: 'new' } }) - const save = labels.parentElement?.querySelector('button') - if (!(save instanceof HTMLButtonElement)) { - throw new Error('Missing labels save button') - } - fireEvent.click(save) - await flushPromises() - await act(async () => oldSave.resolve({ ok: false, error: 'Title rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('new')).toBeDefined() - await act(async () => newerSave.resolve({ ok: false, error: 'Labels rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('Old title')).toBeDefined() - expect(screen.getByDisplayValue('backend')).toBeDefined() - }) - - it('keeps a newer hydrated rollback baseline when an older refresh returns stale fields', async () => { - const oldSave = deferred<{ ok: true }>() - const oldRefresh = deferred() - const newerSave = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraUpdateIssueForTarget - .mockReturnValueOnce(oldSave.promise) - .mockReturnValueOnce(newerSave.promise) - runtimeMocks.jiraGetIssueForTarget - .mockResolvedValueOnce(jiraIssue('Old title')) - .mockReturnValueOnce(oldRefresh.promise) - .mockResolvedValueOnce(otherIssue) - .mockResolvedValue(jiraIssue('First edit', { labels: ['backend'] })) - render() - await flushPromises() - saveTitle('Old title', 'First edit') - await act(async () => oldSave.resolve({ ok: true })) - await flushPromises() - await act(async () => selectIssue(otherIssue)) - await flushPromises() - await act(async () => selectIssue(jiraIssue('First edit'))) - await flushPromises() - const labels = screen.getByDisplayValue('backend') - fireEvent.change(labels, { target: { value: 'new' } }) - const save = labels.parentElement?.querySelector('button') - if (!(save instanceof HTMLButtonElement)) { - throw new Error('Missing labels save button') - } - fireEvent.click(save) - await flushPromises() - await act(async () => oldRefresh.resolve(jiraIssue('First edit'))) - await flushPromises() - await act(async () => newerSave.resolve({ ok: false, error: 'Labels rejected' })) - await flushPromises() - expect(screen.getByDisplayValue('backend')).toBeDefined() - }) }) diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 980f29cf186..00ff80f3cfc 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -1,28 +1,15 @@ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Jira issue hydration, comments, transitions, priorities, and user options are loaded from provider IPC for the selected issue. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo } from 'react' import { toast } from 'sonner' import * as VisuallyHidden from 'radix-ui/visually-hidden' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' - -import { createBrowserUuid } from '@/lib/browser-uuid' +import { hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' import { useAppStore } from '@/store' +import { jiraGetIssueForTarget, jiraUpdateIssueForTarget } from '@/runtime/runtime-jira-client' +import type { JiraIssue, JiraMutationResult } from '../../../shared/jira-types' import { - jiraAddIssueCommentForTarget, - jiraGetIssueForTarget, - jiraListPrioritiesForTarget, - jiraUpdateIssueForTarget -} from '@/runtime/runtime-jira-client' -import type { - JiraComment, - JiraIssue, - JiraMutationResult, - JiraPriority -} from '../../../shared/jira-types' -import type { TaskSourceContext } from '../../../shared/task-source-context' + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { JiraIssueMetadataBar, JiraIssueWorkspaceHeader } from './jira-issue-workspace-chrome' import { JiraIssueCommentComposer, JiraIssueWorkspaceContent } from './jira-issue-workspace-content' @@ -32,16 +19,11 @@ import { defaultScopeHost } from '@/lib/default-creation-host' import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target' import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context' import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' -import { - editedJiraIssueFields, - mergeJiraIssueHydration, - type EditableJiraIssueField -} from './jira-issue-workspace-hydration' +import { editedJiraIssueFields } from './jira-issue-workspace-hydration' import { enqueueJiraIssueMutation, jiraIssueRequestScope } from './jira-issue-mutation-queue' -import { useJiraIssueRemoteDetails } from './use-jira-issue-remote-details' import { useJiraIssueMutationState } from './use-jira-issue-mutation-state' -import { getTaskSourceCacheScope } from '../../../shared/task-source-context' - +import { useJiraIssueWorkspaceData } from './use-jira-issue-workspace-data' +import { useJiraIssueCommentSubmission } from './use-jira-issue-comment-submission' type JiraIssueWorkspaceProps = { issue: JiraIssue | null onUse: (issue: JiraIssue) => void @@ -63,33 +45,35 @@ export default function JiraIssueWorkspace({ const runtimeContextKey = getEnvironmentRuntimeContextKey(runtimeTargetEnvironmentId(owner)) const mutationContextKey = getJiraReadIdentity(settings, sourceContext).contextKey const patchJiraIssue = useAppStore((s) => s.patchJiraIssue) - const [fullIssue, setFullIssue] = useState(null) - const [issueLoading, setIssueLoading] = useState(false) - const [priorities, setPriorities] = useState([]) - const [pendingField, setPendingField] = useState(null) - const [titleDraft, setTitleDraft] = useState('') - const [labelsDraft, setLabelsDraft] = useState('') - const [commentDraft, setCommentDraft] = useState('') - const [commentSubmitting, setCommentSubmitting] = useState(false) - const requestIdRef = useRef(0) - const optimisticCommentsRef = useRef([]) + const requestKey = jiraIssueRequestScope(issue, sourceContext, settings) const { comments, setComments, commentsLoading, - setCommentsLoading, commentsError, - setCommentsError, loadComments, transitions, - setTransitions, transitionsLoading, - setTransitionsLoading, transitionsError, - setTransitionsError, - loadTransitions - } = useJiraIssueRemoteDetails(owner, requestIdRef, optimisticCommentsRef) - + loadTransitions, + editedFieldsRef, + fullIssue, + issueLoading, + labelsDraft, + optimisticCommentsRef, + pendingField, + priorities, + requestIdRef, + commentDraft, + commentSubmitting, + setCommentDraft, + setCommentSubmitting, + setFullIssue, + setLabelsDraft, + setPendingField, + setTitleDraft, + titleDraft + } = useJiraIssueWorkspaceData({ issue, owner, requestKey }) const displayed = fullIssue ?? issue const siteId = displayed?.siteId ?? undefined const metadataKey = JSON.stringify([ @@ -97,104 +81,17 @@ export default function JiraIssueWorkspace({ siteId, displayed?.key ]) - // Cache patches replace the issue object without opening a different issue. - const requestKey = jiraIssueRequestScope(issue, sourceContext, settings) - const loadedRequestKeyRef = useRef(null) - const editedFieldsRef = useRef(new Set()) - - // oxlint-disable-next-line react-doctor/no-derived-state-effect -- Why: seeds editable issue drafts while IPC hydration runs and invalidates obsolete requests. - useEffect(() => { - if (issue && loadedRequestKeyRef.current === requestKey) { - return - } - loadedRequestKeyRef.current = requestKey - requestIdRef.current += 1 - setPendingField(null) - setCommentSubmitting(false) - setCommentDraft('') - if (!issue) { - setFullIssue(null) - setIssueLoading(false) - setComments([]) - setCommentsLoading(false) - setCommentsError(null) - setTransitions([]) - setTransitionsError(null) - setTransitionsLoading(false) - setPriorities([]) - optimisticCommentsRef.current = [] - return - } - - const requestId = requestIdRef.current - optimisticCommentsRef.current = [] - editedFieldsRef.current = new Set() - setFullIssue(issue) - setTitleDraft(issue.title) - setLabelsDraft(issue.labels.join(', ')) - setComments([]) - setCommentsError(null) - setTransitions([]) - setTransitionsError(null) - setIssueLoading(true) - - void jiraGetIssueForTarget(owner, issue.key, issue.siteId) - .then((result) => { - if (requestId !== requestIdRef.current) { - return - } - if (result) { - const edited = new Set(editedFieldsRef.current) - setFullIssue((current) => mergeJiraIssueHydration(result, current, edited)) - if (!edited.has('title')) { - setTitleDraft(result.title) - } - if (!edited.has('labels')) { - setLabelsDraft(result.labels.join(', ')) - } - } - }) - .catch(() => {}) - .finally(() => { - if (requestId === requestIdRef.current) { - setIssueLoading(false) - } - }) - - void jiraListPrioritiesForTarget(owner, issue.siteId) - .then((nextPriorities) => { - if (requestId !== requestIdRef.current) { - return - } - setPriorities(nextPriorities) - }) - .catch(() => {}) - - void loadTransitions(issue, requestId) - void loadComments(issue, requestId) - }, [ - issue, - loadComments, - loadTransitions, + const handleSubmitComment = useJiraIssueCommentSubmission({ + commentDraft, + commentSubmitting, + displayed, + optimisticCommentsRef, owner, - requestKey, - setComments, - setCommentsError, - setCommentsLoading, - setTransitions, - setTransitionsError, - setTransitionsLoading, - optimisticCommentsRef - ]) - - useEffect( - () => () => { - requestIdRef.current += 1 - loadedRequestKeyRef.current = null - }, - [] - ) - + requestIdRef, + setCommentDraft, + setCommentSubmitting, + setComments + }) useJiraIssueMutationState({ scope: requestKey, issue, @@ -206,7 +103,6 @@ export default function JiraIssueWorkspace({ onStatusSettled: () => displayed && void loadTransitions(displayed, requestIdRef.current), onCommentsChanged: () => displayed && void loadComments(displayed, requestIdRef.current) }) - const mutateIssue = useCallback( async ( field: string, @@ -264,16 +160,18 @@ export default function JiraIssueWorkspace({ }, [ displayed, + editedFieldsRef, patchJiraIssue, pendingField, mutationContextKey, owner, + requestIdRef, requestKey, + setPendingField, siteId, sourceContext ] ) - const { handleSaveTitle, handleSaveLabels, actionItems } = useJiraIssueWorkspaceActions({ displayed, titleDraft, @@ -282,60 +180,6 @@ export default function JiraIssueWorkspace({ mutateIssue }) - const handleSubmitComment = useCallback(async (): Promise => { - if (!displayed || commentSubmitting) { - return - } - const bodyState = getCommentBodySubmitState(commentDraft) - if (bodyState.status === 'empty') { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.JiraIssueWorkspace.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setCommentSubmitting(true) - const requestId = requestIdRef.current - try { - const result = await jiraAddIssueCommentForTarget( - owner, - displayed.key, - bodyState.body, - displayed.siteId - ) - if (!result.ok) { - throw new Error(result.error) - } - if (requestId !== requestIdRef.current) { - return - } - const comment: JiraComment = { - id: result.id || createBrowserUuid(), - body: bodyState.body, - createdAt: new Date().toISOString(), - user: { accountId: 'local', displayName: 'You' } - } - optimisticCommentsRef.current.push(comment) - setComments((prev) => [...prev, comment]) - setCommentDraft('') - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate('auto.components.JiraIssueWorkspace.fa132c8aed', 'Failed to add comment.') - ) - } finally { - if (requestId === requestIdRef.current) { - setCommentSubmitting(false) - } - } - }, [commentDraft, commentSubmitting, displayed, owner, optimisticCommentsRef, setComments]) - return ( !open && onClose()}> + owner: RuntimeClientTarget + requestIdRef: RefObject + setCommentDraft: (draft: string) => void + setCommentSubmitting: (submitting: boolean) => void + setComments: Dispatch> +} + +export function useJiraIssueCommentSubmission({ + commentDraft, + commentSubmitting, + displayed, + optimisticCommentsRef, + owner, + requestIdRef, + setCommentDraft, + setCommentSubmitting, + setComments +}: JiraIssueCommentSubmissionArgs): () => Promise { + return useCallback(async (): Promise => { + if (!displayed || commentSubmitting) { + return + } + const bodyState = getCommentBodySubmitState(commentDraft) + if (bodyState.status === 'empty') { + return + } + if (bodyState.status === 'too-large-leading-whitespace') { + toast.error( + translate( + 'auto.components.JiraIssueWorkspace.commentTooLarge', + 'Comment is too large to submit safely.' + ) + ) + return + } + setCommentSubmitting(true) + const requestId = requestIdRef.current + try { + const result = await jiraAddIssueCommentForTarget( + owner, + displayed.key, + bodyState.body, + displayed.siteId + ) + if (!result.ok) { + throw new Error(result.error) + } + if (requestId !== requestIdRef.current) { + return + } + const comment: JiraComment = { + id: result.id || createBrowserUuid(), + body: bodyState.body, + createdAt: new Date().toISOString(), + user: { accountId: 'local', displayName: 'You' } + } + optimisticCommentsRef.current.push(comment) + setComments((previous) => [...previous, comment]) + setCommentDraft('') + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate('auto.components.JiraIssueWorkspace.fa132c8aed', 'Failed to add comment.') + ) + } finally { + if (requestId === requestIdRef.current) { + setCommentSubmitting(false) + } + } + }, [ + commentDraft, + commentSubmitting, + displayed, + optimisticCommentsRef, + owner, + requestIdRef, + setCommentDraft, + setCommentSubmitting, + setComments + ]) +} diff --git a/src/renderer/src/components/use-jira-issue-workspace-data.ts b/src/renderer/src/components/use-jira-issue-workspace-data.ts new file mode 100644 index 00000000000..670e471c1e5 --- /dev/null +++ b/src/renderer/src/components/use-jira-issue-workspace-data.ts @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react' +import type { JiraComment, JiraIssue, JiraPriority } from '../../../shared/jira-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { jiraGetIssueForTarget, jiraListPrioritiesForTarget } from '@/runtime/runtime-jira-client' +import { + mergeJiraIssueHydration, + type EditableJiraIssueField +} from './jira-issue-workspace-hydration' +import { useJiraIssueRemoteDetails } from './use-jira-issue-remote-details' + +type JiraIssueWorkspaceDataArgs = { + issue: JiraIssue | null + owner: RuntimeClientTarget + requestKey: string | null +} + +export function useJiraIssueWorkspaceData({ + issue, + owner, + requestKey +}: JiraIssueWorkspaceDataArgs) { + const [fullIssue, setFullIssue] = useState(null) + const [issueLoading, setIssueLoading] = useState(false) + const [priorities, setPriorities] = useState([]) + const [pendingField, setPendingField] = useState(null) + const [titleDraft, setTitleDraft] = useState('') + const [labelsDraft, setLabelsDraft] = useState('') + const [commentDraft, setCommentDraft] = useState('') + const [commentSubmitting, setCommentSubmitting] = useState(false) + const requestIdRef = useRef(0) + const optimisticCommentsRef = useRef([]) + const editedFieldsRef = useRef(new Set()) + const remoteDetails = useJiraIssueRemoteDetails(owner, requestIdRef, optimisticCommentsRef) + const { + loadComments, + loadTransitions, + setComments, + setCommentsError, + setCommentsLoading, + setTransitions, + setTransitionsError, + setTransitionsLoading + } = remoteDetails + const loadedRequestKeyRef = useRef(null) + + // oxlint-disable-next-line react-doctor/no-derived-state-effect -- Why: seeds editable issue drafts while IPC hydration runs and invalidates obsolete requests. + useEffect(() => { + if (issue && loadedRequestKeyRef.current === requestKey) { + return + } + loadedRequestKeyRef.current = requestKey + requestIdRef.current += 1 + setPendingField(null) + setCommentSubmitting(false) + setCommentDraft('') + if (!issue) { + setFullIssue(null) + setIssueLoading(false) + setComments([]) + setCommentsLoading(false) + setCommentsError(null) + setTransitions([]) + setTransitionsError(null) + setTransitionsLoading(false) + setPriorities([]) + optimisticCommentsRef.current = [] + return + } + + const requestId = requestIdRef.current + optimisticCommentsRef.current = [] + editedFieldsRef.current = new Set() + setFullIssue(issue) + setTitleDraft(issue.title) + setLabelsDraft(issue.labels.join(', ')) + setComments([]) + setCommentsError(null) + setTransitions([]) + setTransitionsError(null) + setIssueLoading(true) + + void jiraGetIssueForTarget(owner, issue.key, issue.siteId) + .then((result) => { + if (requestId !== requestIdRef.current || !result) { + return + } + const edited = new Set(editedFieldsRef.current) + setFullIssue((current) => mergeJiraIssueHydration(result, current, edited)) + if (!edited.has('title')) { + setTitleDraft(result.title) + } + if (!edited.has('labels')) { + setLabelsDraft(result.labels.join(', ')) + } + }) + .catch(() => {}) + .finally(() => { + if (requestId === requestIdRef.current) { + setIssueLoading(false) + } + }) + + void jiraListPrioritiesForTarget(owner, issue.siteId) + .then((nextPriorities) => { + if (requestId === requestIdRef.current) { + setPriorities(nextPriorities) + } + }) + .catch(() => {}) + + void loadTransitions(issue, requestId) + void loadComments(issue, requestId) + }, [ + issue, + loadComments, + loadTransitions, + owner, + requestKey, + setComments, + setCommentsError, + setCommentsLoading, + setTransitions, + setTransitionsError, + setTransitionsLoading + ]) + + useEffect( + () => () => { + requestIdRef.current += 1 + loadedRequestKeyRef.current = null + }, + [] + ) + + return { + ...remoteDetails, + commentDraft, + commentSubmitting, + editedFieldsRef, + fullIssue, + issueLoading, + labelsDraft, + optimisticCommentsRef, + pendingField, + priorities, + requestIdRef, + setCommentDraft, + setCommentSubmitting, + setFullIssue, + setLabelsDraft, + setPendingField, + setPriorities, + setTitleDraft, + titleDraft + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 7a2ff4538ee..e7bc4cb31e9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -63,6 +63,70 @@ import { MOBILE_RELAY_RUNTIME_CAPABILITIES } from './mobile-desktop-relay-contra import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' import { ACCOUNT_RUNTIME_CAPABILITIES } from './account-runtime-capabilities' import * as agentLaunchCapabilities from './agent-launch-runtime-capability' +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, + AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, + AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, + AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AI_VAULT_RUNTIME_CAPABILITY, + AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, + ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, + BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY, + BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY, + BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY, + BROWSER_SCREENCAST_RUNTIME_CAPABILITY, + BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY, + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, + GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, + GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, + LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, + NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, + REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, + RUNTIME_AUTOMATIC_WORK_RUNTIME_CAPABILITY, + RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY, + TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, + TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, + TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, + TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY, + WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY, + WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY +} from './runtime-capability-constants' // Why: declares the Orca runtime RPC compatibility contract. Desktop, // headless server, CLI, and mobile builds may drift in app version, but @@ -87,252 +151,11 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 -export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const -export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const -export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const -export const WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY = 'worktree.provisioned-root.v1' as const -export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = - 'worktree.linked-work-item-context.v1' as const -export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY = - 'worktree.github-pr-suppression.v1' as const -// Legacy clients cannot safely derive a branch name from a qualified ref selector. -export const REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY = - 'repo.search-qualified-refs.v1' as const -export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const -export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = - 'folder-workspace.path-status.v1' as const -export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY = - 'linear.issue-attribute-filter.v1' as const -// Why: signals the host exposes the Agent Session History scanner over RPC -// (aiVault.listSessions). Registered unconditionally for every build, so it is a -// STATIC capability advertised by getStatus() automatically — NOT a runtime -// conditional like browser.headless.v1. -export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const -export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const -// Why: signals a host owns browser pages with no renderer (headless serve via the -// offscreen backend). Advertised only when that backend is actually available, so -// clients never fall back to a local desktop browser tab for a remote-owned page. -export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const -export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const -// Why conditional: only a desktop runtime holds the SSH registry managed servers are run from. -export const MANAGED_SERVER_RUNTIME_CAPABILITY = 'managedServer.v1' as const -export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const -export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const -// Why: older hosts discard browser.tabCreate's page field, so clients may only -// treat a preallocated page ID as canonical when this is advertised. -export const BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY = - 'browser.tab-create-known-id.v1' as const -export const BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY = 'browser.clientHost.v1' as const -export const BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY = - 'browser.clientHost.pageMetadata.v1' as const -export const BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY = - 'browser.clientHost.automation.v1' as const -// Why: without it a client-placed browser.upload would resolve remote paths on the desktop filesystem, so uploads fail closed instead. -export const BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY = - 'browser.clientHost.fileChannel.v1' as const -export const BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY = 'network.browserTunnel.v1' as const -export const BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY = - 'network.browserTunnel.executionHosts.v1' as const -// Why: hosts without this strip terminal.send's inputKind (zod object drops -// unknown keys), so a mobile xterm query reply would land as ordinary -// floor-taking input. Mobile must not forward replies unless advertised. -export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = - 'terminal.query-reply-input.v1' as const -// Why: without this, prompt request IDs and waitSubmitMs are stripped and a retry would resend raw input. -export const TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY = 'terminal.prompt-delivery.v1' as const -// Why: paired clients may unmount xterm only when the host can return a -// bounded, sequenced scrollback snapshot for lossless reveal. -export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const -// Why: terminal.ensureAgentSession params are strict, so an older host rejects `spawnIntent`; a -// host that advertises this also may answer a declared restore with `terminal_spawn_deferred`. -export const TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY = 'terminal.spawn-intent.v1' as const -// Why: clients offer "Resume automatic restores" only on hosts that can clear a recorded pause. -export const RUNTIME_AUTOMATIC_WORK_RUNTIME_CAPABILITY = 'runtime.automatic-work.v1' as const -// Why: older hosts lack the targeted settings RPCs and strip agentPrompt from -// terminal creation, so mobile must hide Quick Commands unless both are present. -export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const -// Why: older hosts strip worktree.create's clientMutationId, so mobile must only -// replay ambiguous cutovers when the host advertises idempotent create support; -// status.worktreeCreateIdempotency carries the optional host retention policy. -export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = - 'worktree.create-idempotency.v1' as const -// Scope of the claim: a hook that RUNS and fails cannot delete the checkout. It does not promise -// the hook was found — an SSH host whose orca.yaml cannot be read answers "no hook" and the removal -// proceeds, because a failed read is indistinguishable from an absent file across the relay -// (#20196 tracks the provider contract that would separate them). -// Why (#19334): "accepts --run-hooks" and "refuses to delete when the archive hook fails" were -// indistinguishable from the outside — both take the flag and behave identically on success, so -// the only way to tell an unfixed host apart was to fail a hook and see whether the checkout -// survived. Lifecycle integrations keep teardown evidence inside the checkout and cannot risk -// that. Advertised unconditionally: every build carrying this constant has the gate. -export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = - 'worktree.archive-failure-blocking.v1' as const -// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. -export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = - 'terminal.create-idempotency.v2' as const -// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running -// the host default shell. That reply is indistinguishable from success, so a client asking for a -// shell must refuse rather than create the wrong one. -export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY = - 'terminal.create-shell-selection.v1' as const -export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const -export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = - 'session-tabs.authoritative-inventory.v1' as const -// Why: a client advertising this retains every terminal retirement proof it receives until the -// surface is published live again, so a session-tabs stream sends each proof once instead of -// repeating the host's whole bounded list on every title tick. -export const SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY = - 'session-tabs.retirement-proof-delta.v1' as const -export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = - 'agent-session.session-boundary.v1' as const +export * from './runtime-capability-constants' export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export * from './agent-session-stop-capabilities' -export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = - 'agent-session.host-authority.v1' as const -// Older launch schemas reject unknown fields; advertise before clients send keyboard support. -export const AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY = 'agent-session.keyboard.v1' as const -export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = - 'agent-session.omp-resume-path.v1' as const -// Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not -// receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; -// the host still refuses agentSession.* methods and destructive tab mutations without capability. -export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const -// Why: hosts before this gated `agentSession.*` on their own chat setting while still advertising -// agent-session.structured.v1, so that capability alone cannot tell a client its chat will be -// admitted. A host advertising this admits by client capability alone; a client advertising it -// picks each launch's mode itself. A client without it asks `agentSession.createSupport` to pick -// for it (released phones), so the host keeps answering that with its own setting. -export const STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY = - 'agent-session.structured.client-launch-mode.v1' as const -// Why: older structured clients render durable pending replies as uncertain delivery. Capable -// clients skip the host's bounded best-effort settlement observation. -export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = - 'agent-session.pending-send-result.v1' as const -// Why: a send is now answered once the host accepts it, before any agent has it. A client without -// this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. Transitional: drop the hold once no supported desktop or -// mobile client lacks the capability; mobile must first show a rejected message in place. -export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = - 'agent-session.accepted-send.v1' as const -// Why: a host advertising this answers a resent send id from its record before anything else may -// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown -// refusal included (host not installed, journal database won't open, host disabled). Reading a -// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — -// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was -// written. `agent_session_operation_expired` — only the transcript can tell. An -// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, -// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is -// gone or unreadable on this host). Any other — the chat holds no message under that id and none -// is in flight, but a resend of that id may still run as a new send, so a client that hands the -// text back must not resend the old id. An older host may refuse an id it recorded: none of this -// holds there. -export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = - 'agent-session.send-answers-proof.v1' as const -// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and -// the client's queueing setting and chord, never the published draft list or its card actions: a -// host without it still publishes a message it kept unsent across a restart or a close, and both -// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: -// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. -// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and -// never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent -// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list -// names the card the queue sends next once nothing runs, null while anything holds it; absent (an -// older host) reads as null. A card carries a hold of its own when its conversion failed -// (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant -// gates the rollout. -export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = - 'agent-session.queued-messages.v1' as const -// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`) -// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart -// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off. -// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback. -export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = - 'agent-session.queued-message-edit.v1' as const -// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects -// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued -// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too: -// the card is the only place the waiting command shows. -export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = - 'agent-session.queued-commands.v1' as const -// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific -// journal and lifecycle surfaces independently from Codex support. -export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = - 'agent-session.structured.claude.v1' as const -// Why: agentSession.conversationOutline is additive; a client probes this before calling so an -// older host leaves the message rail on loaded messages instead of answering method_not_found. -export const AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY = - 'agent-session.conversation-outline.v1' as const -// The RPC is registered unconditionally; per-session rewind support is a separate check. -export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const -// Why: the rewind UI relies on the next send settling an in-doubt rewind, even with the agent up. -export const AGENT_SESSION_REWIND_RECOVERY_CAPABILITY = 'agent-session.rewind-recovery.v1' as const -// Readers must understand a monitoring roster with no available stop control. -// Why: a `turn` journal item replaced the status row that used to carry a turn's lifecycle. A -// client that predates it would render the unknown kind as text, so the host publishes the legacy -// status form to clients that do not advertise this. Transitional: drop the downgrade once no -// supported release lacks the capability. -export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const -export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = - 'agent-session.background-task-stop.v1' as const -// Why: agentSession.respondToQuestion has a strict schema, so clients must not send structured -// `answers` to an older host; they fall back to the answer packed into `optionId`. -export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = - 'agent-session.question-answers.v1' as const -// Why: a structured chat on a paired server stores attached files on that server -// (agentSessionAttachment.*); an older server has no store, so clients must refuse -// the attach rather than hand the agent a path from this machine. -export const AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session.attachments.v1' as const -// Why: the host now publishes rows for work that is live inside a turn, and such -// a row carries `stoppable: false` because no targeted stop can reach it. A -// reader that predates the field draws a per-row Stop on every row it is given, -// so it must be told apart from one that honours the field — and NOT by the -// stop capability above, which a client can advertise while predating this. -export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = - 'agent-session.background-task-row-stop.v1' as const -// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. -export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const -export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = - 'Remote file changes require a newer Orca server. Update the HUB and try again.' -export const GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY = 'github.markPRReadyForReview' as const -export const GITHUB_MARK_PR_READY_UPDATE_REQUIRED_MESSAGE = - 'Marking a pull request ready requires a newer Orca server. Update the server and try again.' -export const GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY = - 'gitlab.updateMR.readyForReview.v1' as const -export const GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE = - 'Marking a merge request ready requires a newer Orca server. Update the server and try again.' -export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = - 'worktree.visibility-defaults.v1' as const -export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = - 'worktree.visibility-source-defaults.v1' as const -// Why: Git's checkout delete outlives worktree.rm's older client timeouts. A client with this waits -// for the reply and shows `removing` rows as Deleting; one without it is answered on acceptance and -// would re-show that row as a normal workspace, so the host leaves such rows out of its listings. -export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = - 'worktree.background-removal.v1' as const -// Why: older hosts discard preflight.detectAgents params and probe their own PATH, so a client -// must not read that answer as the workspace's (a WSL project on a Windows host differs). -export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = - 'preflight.workspace-scoped.v1' as const -// Why: an older host answers its own agents for a workspace another runtime owns; one with this -// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. -export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = - 'preflight.other-runtime-refusal.v1' as const -/** What such a host answers, instead of probing itself, for another runtime's workspace. */ -export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' -// Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act -// only on the endpoint itself, never on a workspace's SSH host. -export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY = - 'workspace-ports.host-scoped.v1' as const -// Hosts without this capability ignore a request's expectedRuntimeSource; the CLI's pre-send probe -// is then the only fence. -export const RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY = 'runtime-source-fence.v1' as const -// Hosts without this capability have no notifications.registerPush RPC. -export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const -// Generic native clients include the CLI and must not claim Electron-only page -// placement support. +// Native clients include the CLI, which cannot place Electron browser pages. export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -344,9 +167,6 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ agentLaunchCapabilities.AGENT_LAUNCH_RUNTIME_CAPABILITY ] as const -export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = - 'git.antigravity-configured-model.v1' as const - export const RUNTIME_CAPABILITIES = [ jiraDetailCapabilities.JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY, JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY, diff --git a/src/shared/runtime-capability-constants.ts b/src/shared/runtime-capability-constants.ts new file mode 100644 index 00000000000..3f540b33f1d --- /dev/null +++ b/src/shared/runtime-capability-constants.ts @@ -0,0 +1,244 @@ +export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const +export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const +export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const +export const WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY = 'worktree.provisioned-root.v1' as const +export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = + 'worktree.linked-work-item-context.v1' as const +export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY = + 'worktree.github-pr-suppression.v1' as const +// Legacy clients cannot safely derive a branch name from a qualified ref selector. +export const REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY = + 'repo.search-qualified-refs.v1' as const +export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const +export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = + 'folder-workspace.path-status.v1' as const +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY = + 'linear.issue-attribute-filter.v1' as const +// Why: signals the host exposes the Agent Session History scanner over RPC +// (aiVault.listSessions). Registered unconditionally for every build, so it is a +// STATIC capability advertised by getStatus() automatically — NOT a runtime +// conditional like browser.headless.v1. +export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const +export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const +// Why: signals a host owns browser pages with no renderer (headless serve via the +// offscreen backend). Advertised only when that backend is actually available, so +// clients never fall back to a local desktop browser tab for a remote-owned page. +export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const +// Why conditional: only a desktop runtime holds the SSH registry managed servers are run from. +export const MANAGED_SERVER_RUNTIME_CAPABILITY = 'managedServer.v1' as const +export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const +export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const +// Why: older hosts discard browser.tabCreate's page field, so clients may only +// treat a preallocated page ID as canonical when this is advertised. +export const BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY = + 'browser.tab-create-known-id.v1' as const +export const BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY = 'browser.clientHost.v1' as const +export const BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY = + 'browser.clientHost.pageMetadata.v1' as const +export const BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY = + 'browser.clientHost.automation.v1' as const +// Why: without it a client-placed browser.upload would resolve remote paths on the desktop filesystem, so uploads fail closed instead. +export const BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY = + 'browser.clientHost.fileChannel.v1' as const +export const BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY = 'network.browserTunnel.v1' as const +export const BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY = + 'network.browserTunnel.executionHosts.v1' as const +// Why: hosts without this strip terminal.send's inputKind (zod object drops +// unknown keys), so a mobile xterm query reply would land as ordinary +// floor-taking input. Mobile must not forward replies unless advertised. +export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = + 'terminal.query-reply-input.v1' as const +// Why: without this, prompt request IDs and waitSubmitMs are stripped and a retry would resend raw input. +export const TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY = 'terminal.prompt-delivery.v1' as const +// Why: paired clients may unmount xterm only when the host can return a +// bounded, sequenced scrollback snapshot for lossless reveal. +export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const +// Why: terminal.ensureAgentSession params are strict, so an older host rejects `spawnIntent`; a +// host that advertises this also may answer a declared restore with `terminal_spawn_deferred`. +export const TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY = 'terminal.spawn-intent.v1' as const +// Why: clients offer "Resume automatic restores" only on hosts that can clear a recorded pause. +export const RUNTIME_AUTOMATIC_WORK_RUNTIME_CAPABILITY = 'runtime.automatic-work.v1' as const +// Why: older hosts lack the targeted settings RPCs and strip agentPrompt from +// terminal creation, so mobile must hide Quick Commands unless both are present. +export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const +// Why: older hosts strip worktree.create's clientMutationId, so mobile must only +// replay ambiguous cutovers when the host advertises idempotent create support; +// status.worktreeCreateIdempotency carries the optional host retention policy. +export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = + 'worktree.create-idempotency.v1' as const +// Scope of the claim: a hook that RUNS and fails cannot delete the checkout. It does not promise +// the hook was found — an SSH host whose orca.yaml cannot be read answers "no hook" and the removal +// proceeds, because a failed read is indistinguishable from an absent file across the relay +// (#20196 tracks the provider contract that would separate them). +// Why (#19334): "accepts --run-hooks" and "refuses to delete when the archive hook fails" were +// indistinguishable from the outside — both take the flag and behave identically on success, so +// the only way to tell an unfixed host apart was to fail a hook and see whether the checkout +// survived. Lifecycle integrations keep teardown evidence inside the checkout and cannot risk +// that. Advertised unconditionally: every build carrying this constant has the gate. +export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = + 'worktree.archive-failure-blocking.v1' as const +// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. +export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = + 'terminal.create-idempotency.v2' as const +// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running +// the host default shell. That reply is indistinguishable from success, so a client asking for a +// shell must refuse rather than create the wrong one. +export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY = + 'terminal.create-shell-selection.v1' as const +export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = + 'session-tabs.authoritative-inventory.v1' as const +// Why: a client advertising this retains every terminal retirement proof it receives until the +// surface is published live again, so a session-tabs stream sends each proof once instead of +// repeating the host's whole bounded list on every title tick. +export const SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY = + 'session-tabs.retirement-proof-delta.v1' as const +export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = + 'agent-session.session-boundary.v1' as const +export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = + 'agent-session.host-authority.v1' as const +// Older launch schemas reject unknown fields; advertise before clients send keyboard support. +export const AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY = 'agent-session.keyboard.v1' as const +export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = + 'agent-session.omp-resume-path.v1' as const +// Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not +// receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; +// the host still refuses agentSession.* methods and destructive tab mutations without capability. +export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: hosts before this gated `agentSession.*` on their own chat setting while still advertising +// agent-session.structured.v1, so that capability alone cannot tell a client its chat will be +// admitted. A host advertising this admits by client capability alone; a client advertising it +// picks each launch's mode itself. A client without it asks `agentSession.createSupport` to pick +// for it (released phones), so the host keeps answering that with its own setting. +export const STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY = + 'agent-session.structured.client-launch-mode.v1' as const +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const +// Why: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const +// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a +// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and +// the client's queueing setting and chord, never the published draft list or its card actions: a +// host without it still publishes a message it kept unsent across a restart or a close, and both +// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: +// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. +// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and +// never compares a draft id with a submission id. It also publishes the queue's pause once, as +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent +// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list +// names the card the queue sends next once nothing runs, null while anything holds it; absent (an +// older host) reads as null. A card carries a hold of its own when its conversion failed +// (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant +// gates the rollout. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const +// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`) +// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart +// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off. +// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback. +export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = + 'agent-session.queued-message-edit.v1' as const +// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects +// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued +// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too: +// the card is the only place the waiting command shows. +export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = + 'agent-session.queued-commands.v1' as const +// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific +// journal and lifecycle surfaces independently from Codex support. +export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = + 'agent-session.structured.claude.v1' as const +// Why: agentSession.conversationOutline is additive; a client probes this before calling so an +// older host leaves the message rail on loaded messages instead of answering method_not_found. +export const AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY = + 'agent-session.conversation-outline.v1' as const +// The RPC is registered unconditionally; per-session rewind support is a separate check. +export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const +// Why: the rewind UI relies on the next send settling an in-doubt rewind, even with the agent up. +export const AGENT_SESSION_REWIND_RECOVERY_CAPABILITY = 'agent-session.rewind-recovery.v1' as const +// Readers must understand a monitoring roster with no available stop control. +// Why: a `turn` journal item replaced the status row that used to carry a turn's lifecycle. A +// client that predates it would render the unknown kind as text, so the host publishes the legacy +// status form to clients that do not advertise this. Transitional: drop the downgrade once no +// supported release lacks the capability. +export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const +export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = + 'agent-session.background-task-stop.v1' as const +// Why: agentSession.respondToQuestion has a strict schema, so clients must not send structured +// `answers` to an older host; they fall back to the answer packed into `optionId`. +export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = + 'agent-session.question-answers.v1' as const +// Why: a structured chat on a paired server stores attached files on that server +// (agentSessionAttachment.*); an older server has no store, so clients must refuse +// the attach rather than hand the agent a path from this machine. +export const AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session.attachments.v1' as const +// Why: the host now publishes rows for work that is live inside a turn, and such +// a row carries `stoppable: false` because no targeted stop can reach it. A +// reader that predates the field draws a per-row Stop on every row it is given, +// so it must be told apart from one that honours the field — and NOT by the +// stop capability above, which a client can advertise while predating this. +export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = + 'agent-session.background-task-row-stop.v1' as const +// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. +export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const +export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = + 'Remote file changes require a newer Orca server. Update the HUB and try again.' +export const GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY = 'github.markPRReadyForReview' as const +export const GITHUB_MARK_PR_READY_UPDATE_REQUIRED_MESSAGE = + 'Marking a pull request ready requires a newer Orca server. Update the server and try again.' +export const GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY = + 'gitlab.updateMR.readyForReview.v1' as const +export const GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE = + 'Marking a merge request ready requires a newer Orca server. Update the server and try again.' +export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = + 'worktree.visibility-defaults.v1' as const +export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = + 'worktree.visibility-source-defaults.v1' as const +// Why: Git's checkout delete outlives worktree.rm's older client timeouts. A client with this waits +// for the reply and shows `removing` rows as Deleting; one without it is answered on acceptance and +// would re-show that row as a normal workspace, so the host leaves such rows out of its listings. +export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = + 'worktree.background-removal.v1' as const +// Why: older hosts discard preflight.detectAgents params and probe their own PATH, so a client +// must not read that answer as the workspace's (a WSL project on a Windows host differs). +export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = + 'preflight.workspace-scoped.v1' as const +// Why: an older host answers its own agents for a workspace another runtime owns; one with this +// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. +export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = + 'preflight.other-runtime-refusal.v1' as const +/** What such a host answers, instead of probing itself, for another runtime's workspace. */ +export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' +// Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act +// only on the endpoint itself, never on a workspace's SSH host. +export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY = + 'workspace-ports.host-scoped.v1' as const +// Hosts without this capability ignore a request's expectedRuntimeSource; the CLI's pre-send probe +// is then the only fence. +export const RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY = 'runtime-source-fence.v1' as const +// Hosts without this capability have no notifications.registerPush RPC. +export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const + +export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = + 'git.antigravity-configured-model.v1' as const