From e5e6189f968ec3b64bc5e695fb06395aae507a39 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:34:33 -0700 Subject: [PATCH] Split Jira issue workspace hooks and IPC param checks (#28759) * refactor(jira): split issue workspace hooks and IPC param checks - Move IPC argument normalization into jira-ipc-params.ts so the jira:* handlers and its tests share one validator. Issue edits are now also checked for labelOperations, which the host reads, so malformed label edits are rejected before they reach Jira. - Move issue hydration and remote detail state out of JiraIssueWorkspace.tsx into use-jira-issue-workspace-data.ts, and comment submission into use-jira-issue-comment-submission.ts, so the component stays under the file-size limit. - Split the reopened-issue and rollback optimistic-edit tests into their own files to keep each test file focused. - Move runtime capability constants into runtime-capability-constants.ts and re-export them from protocol-version.ts, so the values keep the same names and exports for existing importers. * refactor(protocol): use named imports for runtime capability constants Import the runtime capability constants by name instead of destructuring them from a namespace object. The bindings and values are unchanged. Named imports make the dependency explicit to readers and tooling, and the protocol version comment now sits next to the constants it describes. --- .../operation-module-loader.test.ts | 13 + .../rpc-recording/operation-module-loader.ts | 13 +- src/main/ipc/jira-ipc-params.test.ts | 65 ++++ src/main/ipc/jira-ipc-params.ts | 78 ++++ src/main/ipc/jira.ts | 109 ++---- ...eWorkspace.optimistic-edit-reopen.test.tsx | 252 +++++++++++++ ...orkspace.optimistic-edit-rollback.test.tsx | 119 +++++++ ...iraIssueWorkspace.optimistic-edit.test.tsx | 333 ------------------ .../src/components/JiraIssueWorkspace.tsx | 242 +++---------- .../use-jira-issue-comment-submission.ts | 97 +++++ .../use-jira-issue-workspace-data.ts | 156 ++++++++ src/shared/protocol-version.ts | 312 ++++------------ src/shared/runtime-capability-constants.ts | 244 +++++++++++++ 13 files changed, 1175 insertions(+), 858 deletions(-) create mode 100644 src/main/ipc/jira-ipc-params.test.ts create mode 100644 src/main/ipc/jira-ipc-params.ts create mode 100644 src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx create mode 100644 src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-rollback.test.tsx create mode 100644 src/renderer/src/components/use-jira-issue-comment-submission.ts create mode 100644 src/renderer/src/components/use-jira-issue-workspace-data.ts create mode 100644 src/shared/runtime-capability-constants.ts 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