diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx index fccef81792..ee1a572679 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx @@ -24,16 +24,16 @@ import { useGitHubRepositories, useGitLabRepositories, useReviewConfig, - useReviewConfigCacheReader, useSaveReviewConfig, } from '@/lib/hooks/use-code-reviewer'; +import { useRepoSelectionToggle } from '@/lib/hooks/use-code-reviewer-repo-selection'; import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; export default function ReposRoute() { const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>(); const { data } = useReviewConfig(scope, platform); const save = useSaveReviewConfig(scope, platform); - const readConfig = useReviewConfigCacheReader(scope, platform); + const toggleRepo = useRepoSelectionToggle(scope, platform); const capabilities = PLATFORM_CAPABILITIES[platform]; const mode = data?.repositorySelectionMode ?? 'all'; const githubRepos = useGitHubRepositories(scope, platform === 'github' && mode === 'selected'); @@ -110,17 +110,6 @@ export default function ReposRoute() { save.mutate({ repositorySelectionMode: nextMode }); }; - const toggleRepo = (id: number | string) => { - // Read the cache at call time, not the render-time snapshot above, so - // two rapid taps each build the next array from the latest committed - // selection instead of dropping one another. - const current = readConfig()?.selectedRepositoryIds ?? []; - const next = current.includes(id) - ? current.filter(existing => existing !== id) - : [...current, id]; - save.mutate({ selectedRepositoryIds: next }); - }; - return ( diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/review-memory.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/review-memory.tsx new file mode 100644 index 0000000000..25225c95a0 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/review-memory.tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { ReviewMemoryScreen } from '@/components/code-reviewer/review-memory-screen'; + +export default function CodeReviewerReviewMemoryRoute() { + const { scope } = useLocalSearchParams<{ scope: string }>(); + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/audit-report.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/audit-report.tsx new file mode 100644 index 0000000000..04853586ab --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/audit-report.tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { AuditReportScreen } from '@/components/security-agent/audit-report-screen'; + +export default function SecurityAgentAuditReportRoute() { + const { scope } = useLocalSearchParams<{ scope: string }>(); + return ; +} diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 449437e0f9..a2fe0cdf62 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -15,6 +15,7 @@ import { import { useFormSheetDetents } from '@/lib/form-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useSecurityLifecycleInvalidation } from '@/lib/hooks/use-security-lifecycle-invalidation'; import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount'; /** @@ -85,6 +86,7 @@ function PushRegistrationMount() { export default function AppLayout() { const colors = useThemeColors(); const { fullSheetDetent } = useFormSheetDetents(); + useSecurityLifecycleInvalidation(); return ( diff --git a/apps/mobile/src/components/agents/message-visibility.test.ts b/apps/mobile/src/components/agents/message-visibility.test.ts index 64b6c04a0c..340c4c2bf3 100644 --- a/apps/mobile/src/components/agents/message-visibility.test.ts +++ b/apps/mobile/src/components/agents/message-visibility.test.ts @@ -44,6 +44,17 @@ function toolPart(tool: string): Part { }; } +function patchPart(files: string[]): Part { + return { + id: 'p4', + sessionID: 's1', + messageID: 'm1', + type: 'patch', + hash: 'abc', + files, + }; +} + function assistantMessage(parts: Part[]): StoredMessage { return { info: { @@ -119,6 +130,14 @@ describe('partRendersContent', () => { }; expect(partRendersContent(part)).toBe(true); }); + + it('returns true for a patch part with files', () => { + expect(partRendersContent(patchPart(['src/a.ts', 'src/b.ts']))).toBe(true); + }); + + it('returns false for a patch part with no files', () => { + expect(partRendersContent(patchPart([]))).toBe(false); + }); }); describe('messageRendersContent', () => { diff --git a/apps/mobile/src/components/agents/message-visibility.ts b/apps/mobile/src/components/agents/message-visibility.ts index 212283f531..02ae6bd786 100644 --- a/apps/mobile/src/components/agents/message-visibility.ts +++ b/apps/mobile/src/components/agents/message-visibility.ts @@ -3,6 +3,7 @@ import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; import { isCompactionPart, isFilePart, + isPatchPart, isReasoningPart, isSnapshotProgressPart, isTextPart, @@ -34,7 +35,7 @@ export function partRendersContent(part: Part): boolean { // property of the part, not of the stream. return shouldRenderReasoningPart(part, false); } - return isFilePart(part) || isCompactionPart(part); + return isFilePart(part) || isCompactionPart(part) || (isPatchPart(part) && part.files.length > 0); } /** diff --git a/apps/mobile/src/components/agents/part-renderer.test.ts b/apps/mobile/src/components/agents/part-renderer.test.ts index aafcbd79de..147f1aafac 100644 --- a/apps/mobile/src/components/agents/part-renderer.test.ts +++ b/apps/mobile/src/components/agents/part-renderer.test.ts @@ -1,4 +1,12 @@ -import { type ReasoningPart, type TextPart, type ToolPart } from '@kilocode/cloud-agent-sdk'; +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/test/render-with-providers.tsx */ +import { + type PatchPart, + type ReasoningPart, + type TextPart, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; +import * as React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -6,6 +14,7 @@ import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { PartRenderer } from './part-renderer'; import { ReasoningPartRenderer } from './reasoning-part-renderer'; import { TextPartRenderer } from './text-part-renderer'; +import { PatchToolCardBody } from './tool-cards/patch-tool-card'; import { ToolPartRenderer } from './tool-part-renderer'; vi.mock('./child-session-section', () => ({})); @@ -27,6 +36,21 @@ vi.mock('./text-part-renderer', () => ({ vi.mock('./tool-part-renderer', () => ({ ToolPartRenderer: () => null, })); +// The patch part summary renders `View`/`Text`; the mounted patch-card test +// mounts the real `PatchToolCardBody` + `ToolPatchPreview` chain with only the +// leaf `DiffLine` mocked, so these module mocks keep React Native out of node. +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/pr-review/diff/diff-line', () => ({ DiffLine: 'DiffLine' })); +vi.mock('@/components/ui/icons', () => ({ FileDiff: 'FileDiff' })); +vi.mock('@/components/ui/selectable-text', () => ({ SelectableText: 'SelectableText' })); +vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('./open-part-detail-context', () => ({ useOpenPartDetail: () => undefined })); +vi.mock('./tool-card-display', () => ({ + getToolDisplay: () => ({}), + toolPartHasDetails: () => false, +})); +vi.mock('./tool-cards/generic-tool-card', () => ({ GenericToolCardBody: 'GenericToolCardBody' })); function makeReasoningPart(text: string, ended = true): ReasoningPart { return { @@ -54,6 +78,106 @@ function makeTextPart(text: string, synthetic?: boolean, ended = true): TextPart return part; } +function makePatchPart(files: string[]): PatchPart { + return { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'patch', + hash: 'abc', + files, + }; +} + +const PATCH_TEXT = '*** Begin Patch\n*** Add File: src/a.ts\n+x\n*** End Patch'; + +function makePatchState( + tool: 'patch' | 'apply_patch', + status: ToolPart['state']['status'] +): ToolPart['state'] { + const input = { patchText: PATCH_TEXT }; + const states: Record = { + pending: { status: 'pending', input, raw: '' }, + running: { status: 'running', input, time: { start: 1 } }, + error: { status: 'error', input, error: 'patch failed', time: { start: 1, end: 2 } }, + completed: { + status: 'completed', + input, + output: '', + title: tool, + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; + return states[status]; +} + +function makePatchToolPart( + tool: 'patch' | 'apply_patch', + status: ToolPart['state']['status'] = 'completed' +): ToolPart { + return { + id: 'patch-1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool, + state: makePatchState(tool, status), + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + const props = value.props as Record; + if (typeof value.type === 'function') { + walk((value.type as React.FunctionComponent)(props)); + } else { + walk(props.children); + } + } + } + walk(node); + return matches; +} + +function findText(root: unknown, text: string): React.ReactElement[] { + return findAll( + root, + el => el.type === 'Text' && (el.props as { children?: unknown }).children === text + ); +} + +async function mountPatchBody(part: ToolPart): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(React.createElement(PatchToolCardBody, { part })); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + function makeToolPart(): ToolPart { return { id: 'tool-1', @@ -163,3 +287,45 @@ describe('PartRenderer', () => { expect(toolElement.props).toMatchObject({ modelOptions }); }); }); + +describe('PartRenderer patch part summary', () => { + it('renders the file count and paths for a patch part', () => { + const part = makePatchPart(['src/a.ts', 'src/b.ts']); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(result).not.toBeNull(); + expect(findText(result, 'Updated 2 files')).toHaveLength(1); + expect(findText(result, 'src/a.ts')).toHaveLength(1); + expect(findText(result, 'src/b.ts')).toHaveLength(1); + }); + + it('uses the singular label for a single file', () => { + const part = makePatchPart(['src/a.ts']); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(findText(result, 'Updated 1 file')).toHaveLength(1); + }); + + it('returns null for a patch part with no files', () => { + const part = makePatchPart([]); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(result).toBeNull(); + }); +}); + +describe('PatchToolCardBody mounted diff lines', () => { + it.each( + (['patch', 'apply_patch'] as const).flatMap(tool => + (['pending', 'running', 'completed', 'error'] as const).map(status => [tool, status] as const) + ) + )('renders diff lines for tool %s in the %s state', async (tool, status) => { + const renderer = await mountPatchBody(makePatchToolPart(tool, status)); + const diffLines = renderer.root.findAll(node => String(node.type) === 'DiffLine'); + expect(diffLines).toHaveLength(1); + const errorLines = renderer.root.findAll( + node => String(node.type) === 'SelectableText' && node.props.children === 'patch failed' + ); + expect(errorLines).toHaveLength(status === 'error' ? 1 : 0); + }); +}); diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index 01349d872d..9508465bdb 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -1,4 +1,7 @@ import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -10,6 +13,7 @@ import { isCompactionPart, isFilePart, isPartStreaming, + isPatchPart, isReasoningPart, isTextPart, isToolPart, @@ -81,6 +85,26 @@ export function PartRenderer({ if (isCompactionPart(part)) { return ; } - // step-start, step-finish, patch, snapshot, agent, retry, subtask — not rendered + // Standalone PatchPart (`type: 'patch'`) carries only file paths — no diff + // text — so the diff engine cannot apply. The web renderer renders null for + // it (apps/web/src/components/cloud-agent-next/PartRenderer.tsx:398-404). + // If OpenCode ever ships diff text on the part, render it through `DiffLine`. + if (isPatchPart(part)) { + const fileCount = part.files.length; + const summary = `Updated ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; + return ( + + + {summary} + {part.files.map(file => ( + + {file} + + ))} + + + ); + } + // step-start, step-finish, snapshot, agent, retry, subtask — not rendered return null; } diff --git a/apps/mobile/src/components/agents/part-types.test.ts b/apps/mobile/src/components/agents/part-types.test.ts index 429b50ad24..25d7adb5e9 100644 --- a/apps/mobile/src/components/agents/part-types.test.ts +++ b/apps/mobile/src/components/agents/part-types.test.ts @@ -1,7 +1,17 @@ -import { type ReasoningPart, type TextPart } from '@kilocode/cloud-agent-sdk'; +import { + type FilePart, + type PatchPart, + type ReasoningPart, + type TextPart, +} from '@kilocode/cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; -import { isPartStreaming, isSnapshotProgressPart, shouldRenderReasoningPart } from './part-types'; +import { + isPartStreaming, + isPatchPart, + isSnapshotProgressPart, + shouldRenderReasoningPart, +} from './part-types'; function makeReasoningPart(text: string, ended = true): ReasoningPart { return { @@ -51,6 +61,32 @@ describe('isSnapshotProgressPart', () => { }); }); +describe('isPatchPart', () => { + it('is true for a patch part', () => { + const part: PatchPart = { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'patch', + hash: 'abc', + files: ['src/a.ts'], + }; + expect(isPatchPart(part)).toBe(true); + }); + + it('is false for a file part', () => { + const part: FilePart = { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'file', + mime: 'text/plain', + url: 'file:///a.txt', + }; + expect(isPatchPart(part)).toBe(false); + }); +}); + describe('shouldRenderReasoningPart', () => { it('does not render a completed reasoning part with empty text', () => { const part = makeReasoningPart('', true); diff --git a/apps/mobile/src/components/agents/part-types.ts b/apps/mobile/src/components/agents/part-types.ts index deafd72c9d..0623c5c8f9 100644 --- a/apps/mobile/src/components/agents/part-types.ts +++ b/apps/mobile/src/components/agents/part-types.ts @@ -2,6 +2,7 @@ import { type CompactionPart, type FilePart, type Part, + type PatchPart, type ReasoningPart, type TextPart, type ToolPart, @@ -34,6 +35,10 @@ export function isFilePart(part: Part): part is FilePart { return part.type === 'file'; } +export function isPatchPart(part: Part): part is PatchPart { + return part.type === 'patch'; +} + export function isReasoningPart(part: Part): part is ReasoningPart { return part.type === 'reasoning'; } diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts index dedd494dbb..03ad68e257 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts +++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts @@ -1,4 +1,5 @@ import { + Brain, FileSliders, FolderGit2, Gauge, @@ -18,6 +19,8 @@ type OverviewRow = { title: string; subtitle: string; onPress?: () => void; + // A row members may open read-only even when they cannot edit config. + readOnlyAccessible?: boolean; }; /** @@ -31,12 +34,14 @@ export function buildOverviewRows({ models, modelsLoading, onOpenModelPicker, + onOpenReviewMemory, }: { data: ReviewConfigData; capabilities: (typeof PLATFORM_CAPABILITIES)[keyof typeof PLATFORM_CAPABILITIES]; models: ModelOption[]; modelsLoading: boolean; onOpenModelPicker: () => void; + onOpenReviewMemory?: () => void; }): OverviewRow[] { return [ { @@ -90,18 +95,35 @@ export function buildOverviewRows({ ? 'All repositories' : `${data.selectedRepositoryIds.length} selected`, }, + // Review memory is GitHub-only and only offered when the caller wires the + // navigation callback (the overview screen pushes the scope-level route, + // not a per-platform settings field). Members may open it read-only: the + // server allows member reads and the screen ships a member off-state. + ...(onOpenReviewMemory + ? [ + { + field: 'review-memory', + icon: Brain, + title: 'Review memory', + subtitle: 'Proposed REVIEW.md guidance', + onPress: onOpenReviewMemory, + readOnlyAccessible: true, + }, + ] + : []), ]; } -/** Shared onPress resolution for an overview row: no-op when read-only, the - * row's own handler (e.g. the model picker) when it has one, otherwise a - * push to its settings field. */ +/** Shared onPress resolution for an overview row: no-op when read-only unless + * the row is marked read-only-accessible, the row's own handler (e.g. the + * model picker or review memory) when it has one, otherwise a push to its + * settings field. */ export function resolveRowOnPress( row: OverviewRow, canEdit: boolean, pushField: (field: string) => void ): (() => void) | undefined { - if (!canEdit) { + if (!canEdit && !row.readOnlyAccessible) { return undefined; } if ('onPress' in row) { diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx index 57b0f8e9c3..b6efa7447c 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -159,6 +159,14 @@ export function PlatformOverviewScreen({ }, }); }, + onOpenReviewMemory: + platform === 'github' + ? () => { + router.push( + `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/review-memory` as Href + ); + } + : undefined, }); const actionRequiredCopy = diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx index 2d928578b9..da56e5041c 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx @@ -133,6 +133,7 @@ function makeReview(over: Record = {}) { completed_at: null, total_cost_musd: null, check_run_id: 123, + rawIdsRedacted: false, manual_config: { agentConfig: { gate_threshold: 'critical' } }, council_result: null, ...over, @@ -238,6 +239,21 @@ describe('ReviewDetailScreen empty council', () => { }); }); +describe('ReviewDetailScreen redacted check run', () => { + it('renders "Hidden" (not "None") when the check run is redacted', () => { + detail.data = { + success: true, + review: makeReview({ check_run_id: null, rawIdsRedacted: true }), + tokenUsage: { input: 0, output: 0 }, + }; + + const texts = renderScreen(); + + expect(texts).toContain('Hidden'); + expect(texts).not.toContain('None'); + }); +}); + describe('ReviewDetailScreen findings pagination', () => { it('keeps earlier findings visible when "Show more" reveals the 21st', () => { const findings = Array.from({ length: 21 }, (_, i) => ({ diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx index 3d810cf2e4..406ed8d484 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -179,6 +179,7 @@ export function ReviewDetailScreen({ {/* Gate: check-run presence, review status, and threshold when set. */} diff --git a/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx b/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx index 31dafd4aeb..9b35bd41fd 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx @@ -81,14 +81,26 @@ export function CouncilSection({ councilResult }: Readonly<{ councilResult: Coun export function GateSection({ checkRunId, + checkRunRedacted, statusLabel, gateThreshold, -}: Readonly<{ checkRunId: number | null; statusLabel: string; gateThreshold?: string }>) { +}: Readonly<{ + checkRunId: number | null; + checkRunRedacted?: boolean; + statusLabel: string; + gateThreshold?: string; +}>) { + let checkRunLabel = 'None'; + if (checkRunId != null) { + checkRunLabel = `#${checkRunId}`; + } else if (checkRunRedacted) { + checkRunLabel = 'Hidden'; + } return ( Gate - + {gateThreshold ? : null} diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx new file mode 100644 index 0000000000..5d38c3f197 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx @@ -0,0 +1,367 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// Review-memory screen state contract: loading skeleton, retryable summary and +// proposals errors, the feature-disabled off-state (enable CTA for billing +// roles and for a loading/error permission, static text with no CTA for a +// plain member), the empty state, and the paginated happy list. The query +// layer is mocked so each state is driven directly through the screen JSX. + +import { createElement, type ReactElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ReviewMemoryScreen } from './review-memory-screen'; +import { collectAccessibilityLabels, collectText } from './review-memory-screen.test-helpers'; + +const summary = vi.hoisted(() => ({ + isPending: false, + isError: false, + isFetching: false, + data: null as unknown, + refetch: vi.fn(), +})); + +const proposals = vi.hoisted(() => ({ + isPending: false, + isError: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + data: null as unknown, + refetch: vi.fn(), + fetchNextPage: vi.fn(), +})); + +const setEnabled = vi.hoisted(() => ({ + isPending: false, + mutate: vi.fn(), +})); + +const permission = vi.hoisted(() => ({ + status: 'ready' as 'loading' | 'error' | 'ready', + canEdit: false, +})); + +const queryErrors = vi.hoisted(() => ({ + errors: [] as { variant?: string; title?: string; onRetry?: () => void }[], +})); + +const buttons = vi.hoisted(() => ({ + rendered: [] as { children?: unknown; onPress?: () => void; accessibilityLabel?: string }[], +})); + +const flashList = vi.hoisted(() => ({ + onEndReached: null as (() => void) | null, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => summary, + useInfiniteQuery: () => proposals, + useMutation: () => setEnabled, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + reviewMemory: { + getDashboardSummary: { + queryOptions: () => ({}), + queryKey: () => ['summary'], + }, + listProposalsPage: { + infiniteQueryOptions: () => ({}), + }, + setEnabled: { + mutationOptions: () => ({}), + }, + }, + }), +})); + +vi.mock('@/lib/code-reviewer-config', () => ({ + PERSONAL_SCOPE: 'personal', +})); + +vi.mock('@/lib/hooks/use-code-reviewer', () => ({ + useReviewerPermission: () => permission, + useSetReviewMemoryEnabled: () => setEnabled, +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: 'gray' }), +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('@shopify/flash-list', () => ({ + FlashList: (props: { + data?: unknown[]; + renderItem?: (info: { item: unknown; index: number }) => ReactElement; + ListEmptyComponent?: ReactElement; + ListFooterComponent?: ReactElement | null; + onEndReached?: () => void; + }): ReactElement | null => { + flashList.onEndReached = props.onEndReached ?? null; + const data = props.data ?? []; + if (data.length === 0) { + return props.ListEmptyComponent ?? null; + } + return createElement( + 'View', + null, + data.map((item, index) => props.renderItem?.({ item, index })), + props.ListFooterComponent ?? null + ); + }, +})); + +vi.mock('@/components/empty-state', () => ({ + EmptyState: ({ title }: { title: string }) => `EMPTY:${title}`, +})); + +vi.mock('@/components/query-error', () => ({ + QueryError: (props: { variant?: string; title?: string; onRetry?: () => void }) => { + queryErrors.errors.push(props); + return null; + }, +})); + +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); + +vi.mock('@/components/ui/button', () => ({ + Button: (props: { children?: unknown; onPress?: () => void; accessibilityLabel?: string }) => { + buttons.rendered.push(props); + return props.children; + }, +})); + +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); + +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +vi.mock('@/components/ui/icons', () => ({ Brain: 'Brain' })); + +function renderScreen(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(ReviewMemoryScreen, { scope: 'personal' })); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +beforeEach(() => { + summary.isPending = false; + summary.isError = false; + summary.isFetching = false; + summary.data = null; + summary.refetch.mockClear(); + proposals.isPending = false; + proposals.isError = false; + proposals.isFetching = false; + proposals.isFetchingNextPage = false; + proposals.hasNextPage = false; + proposals.data = null; + proposals.refetch.mockClear(); + proposals.fetchNextPage.mockClear(); + setEnabled.isPending = false; + setEnabled.mutate.mockClear(); + permission.status = 'ready'; + permission.canEdit = false; + queryErrors.errors = []; + buttons.rendered = []; + flashList.onEndReached = null; +}); + +describe('ReviewMemoryScreen loading', () => { + it('renders the loading skeleton while the summary loads', () => { + summary.isPending = true; + + const renderer = renderScreen(); + + expect(collectAccessibilityLabels(renderer.toJSON())).toContain('Loading review memory'); + }); +}); + +describe('ReviewMemoryScreen retryable errors', () => { + it('renders a retryable error with Retry when the summary fails', () => { + summary.isError = true; + + renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('server'); + expect(queryErrors.errors[0]?.onRetry).toBeDefined(); + }); + + it('renders a retryable error with Retry when the first proposals page fails', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 0 }; + proposals.isError = true; + + renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('server'); + expect(queryErrors.errors[0]?.onRetry).toBeDefined(); + }); +}); + +describe('ReviewMemoryScreen feature disabled', () => { + it('offers an enable CTA to billing roles', () => { + summary.data = { enabled: false, repositories: [], openProposalCount: 0 }; + permission.canEdit = true; + + renderScreen(); + + const enableButton = buttons.rendered.find( + button => button.accessibilityLabel === 'Enable review memory' + ); + expect(enableButton).toBeDefined(); + expect(enableButton?.onPress).toBeDefined(); + if (enableButton?.onPress) { + act(() => { + enableButton.onPress?.(); + }); + } + expect(setEnabled.mutate).toHaveBeenCalledWith(true); + }); + + it('shows static off-state text with no CTA for a plain member', () => { + summary.data = { enabled: false, repositories: [], openProposalCount: 0 }; + permission.canEdit = false; + + const renderer = renderScreen(); + + expect(collectText(renderer.toJSON())).toContain( + 'Only organization owners and billing managers can enable review memory.' + ); + expect( + buttons.rendered.find(button => button.accessibilityLabel === 'Enable review memory') + ).toBeUndefined(); + }); + + it.each(['loading', 'error'] as const)( + 'shows the enable CTA instead of the member copy when permission is %s', + status => { + summary.data = { enabled: false, repositories: [], openProposalCount: 0 }; + permission.status = status; + permission.canEdit = false; + + const renderer = renderScreen(); + + expect( + buttons.rendered.find(button => button.accessibilityLabel === 'Enable review memory') + ).toBeDefined(); + expect(collectText(renderer.toJSON())).not.toContain( + 'Only organization owners and billing managers can enable review memory.' + ); + } + ); +}); + +describe('ReviewMemoryScreen proposals', () => { + it('renders the empty state when there are no proposals', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 0 }; + proposals.data = { pages: [{ proposals: [], nextCursor: null }] }; + + const renderer = renderScreen(); + + expect(collectText(renderer.toJSON())).toContain('EMPTY:No proposals'); + }); + + it('renders the paginated proposal list', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 1 }; + proposals.data = { + pages: [ + { + proposals: [{ id: 'p1', title: 'Add auth guidance', repo_full_name: 'acme/repo' }], + nextCursor: null, + }, + ], + }; + + const renderer = renderScreen(); + + const texts = collectText(renderer.toJSON()); + expect(texts).toContain('Add auth guidance'); + expect(texts).toContain('acme/repo'); + }); + + it('fetches the next page when the list end is reached', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 2 }; + proposals.data = { + pages: [ + { + proposals: [{ id: 'p1', title: 'First', repo_full_name: 'acme/repo' }], + nextCursor: 'c1', + }, + ], + }; + proposals.hasNextPage = true; + + renderScreen(); + + expect(flashList.onEndReached).toBeDefined(); + act(() => { + flashList.onEndReached?.(); + }); + expect(proposals.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('does not fetch again while a next page is already loading', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 2 }; + proposals.data = { + pages: [ + { + proposals: [{ id: 'p1', title: 'First', repo_full_name: 'acme/repo' }], + nextCursor: 'c1', + }, + ], + }; + proposals.hasNextPage = true; + proposals.isFetchingNextPage = true; + + renderScreen(); + + act(() => { + flashList.onEndReached?.(); + }); + expect(proposals.fetchNextPage).not.toHaveBeenCalled(); + }); + + it('shows the later-page error footer with a retry that fetches the next page', () => { + summary.data = { enabled: true, repositories: [], openProposalCount: 2 }; + proposals.data = { + pages: [ + { + proposals: [{ id: 'p1', title: 'First', repo_full_name: 'acme/repo' }], + nextCursor: 'c1', + }, + ], + }; + proposals.isError = true; + + const renderer = renderScreen(); + + expect(collectText(renderer.toJSON())).toContain("Couldn't load more"); + + const retryButton = buttons.rendered.find( + button => button.accessibilityLabel === 'Retry loading more' + ); + expect(retryButton).toBeDefined(); + act(() => { + retryButton?.onPress?.(); + }); + expect(proposals.fetchNextPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.test-helpers.ts b/apps/mobile/src/components/code-reviewer/review-memory-screen.test-helpers.ts new file mode 100644 index 0000000000..3bea914b99 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.test-helpers.ts @@ -0,0 +1,34 @@ +// Test helpers shared by the review-memory mounted tests: walk a rendered +// react-test-renderer tree and collect text or accessibility labels. Kept in a +// separate module so the test file stays under the repo's max-lines limit. + +export function collectText(node: unknown): string[] { + if (node == null) { + return []; + } + if (typeof node === 'string') { + return [node]; + } + if (Array.isArray(node)) { + return node.flatMap(n => collectText(n)); + } + if (typeof node === 'object' && 'children' in node) { + return collectText((node as { children?: unknown }).children); + } + return []; +} + +export function collectAccessibilityLabels(node: unknown): string[] { + if (node == null) { + return []; + } + if (Array.isArray(node)) { + return node.flatMap(n => collectAccessibilityLabels(n)); + } + if (typeof node === 'object') { + const obj = node as { props?: { accessibilityLabel?: string }; children?: unknown }; + const own = obj.props?.accessibilityLabel ? [obj.props.accessibilityLabel] : []; + return [...own, ...collectAccessibilityLabels(obj.children)]; + } + return []; +} diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx new file mode 100644 index 0000000000..81198a3d9c --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx @@ -0,0 +1,190 @@ +import { FlashList } from '@shopify/flash-list'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { ActivityIndicator, View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; +import { Brain } from '@/components/ui/icons'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { PERSONAL_SCOPE } from '@/lib/code-reviewer-config'; +import { useReviewerPermission, useSetReviewMemoryEnabled } from '@/lib/hooks/use-code-reviewer'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useTRPC } from '@/lib/trpc'; + +const PAGE_SIZE = 20; + +// Review memory only exists for GitHub, so the owner input pins the platform +// and only varies the scope segment (personal vs. an organization id). +function reviewMemoryOwnerInput(scope: string) { + return scope === PERSONAL_SCOPE + ? ({ platform: 'github' } as const) + : ({ organizationId: scope, platform: 'github' } as const); +} + +export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { + const trpc = useTRPC(); + const colors = useThemeColors(); + const ownerInput = reviewMemoryOwnerInput(scope); + const permission = useReviewerPermission(scope); + + const summaryQuery = useQuery(trpc.reviewMemory.getDashboardSummary.queryOptions(ownerInput)); + const enabled = summaryQuery.data?.enabled === true; + + const proposalsQuery = useInfiniteQuery( + trpc.reviewMemory.listProposalsPage.infiniteQueryOptions( + { ...ownerInput, limit: PAGE_SIZE }, + { + enabled, + getNextPageParam: lastPage => lastPage.nextCursor ?? undefined, + } + ) + ); + + const setEnabled = useSetReviewMemoryEnabled(scope); + + const proposals = useMemo( + () => (proposalsQuery.data?.pages ?? []).flatMap(page => page.proposals), + [proposalsQuery.data?.pages] + ); + + const readOnly = permission.status === 'ready' && !permission.canEdit; + const hasLoadedPages = (proposalsQuery.data?.pages.length ?? 0) > 0; + const firstPageError = proposalsQuery.isError && !hasLoadedPages; + const laterPageError = proposalsQuery.isError && hasLoadedPages; + + const summaryLoading = summaryQuery.isPending; + const summaryError = summaryQuery.isError && !summaryQuery.data; + const disabled = summaryQuery.data != null && !summaryQuery.data.enabled; + const proposalsLoading = enabled && proposalsQuery.isPending; + const empty = enabled && !proposalsLoading && !firstPageError && proposals.length === 0; + const happy = enabled && !proposalsLoading && !firstPageError && proposals.length > 0; + + let footer = null; + if (laterPageError) { + footer = ( + + + Couldn't load more + + + + ); + } else if (proposalsQuery.isFetchingNextPage) { + footer = ( + + + + ); + } + + return ( + + + proposal.id} + renderItem={({ item }) => ( + + + {item.title} + + + {item.repo_full_name} + + + )} + ListEmptyComponent={ + + {summaryLoading && ( + + + + + + )} + + {summaryError && ( + void summaryQuery.refetch()} + isRetrying={summaryQuery.isFetching} + /> + )} + + {disabled && ( + + Review memory is off + + Turn it on to let Kilo learn from maintainer replies and propose REVIEW.md + guidance. + + {readOnly ? ( + + Only organization owners and billing managers can enable review memory. + + ) : ( + + )} + + )} + + {proposalsLoading && ( + + + + + + )} + + {firstPageError && ( + void proposalsQuery.refetch()} + isRetrying={proposalsQuery.isFetching} + /> + )} + + {empty && ( + + )} + + } + ListFooterComponent={footer} + onEndReached={() => { + if (proposalsQuery.hasNextPage && !proposalsQuery.isFetchingNextPage) { + void proposalsQuery.fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + /> + + ); +} diff --git a/apps/mobile/src/components/notifications-screen.mounted.test.tsx b/apps/mobile/src/components/notifications-screen.mounted.test.tsx index 64611ad6ce..03b3bf9466 100644 --- a/apps/mobile/src/components/notifications-screen.mounted.test.tsx +++ b/apps/mobile/src/components/notifications-screen.mounted.test.tsx @@ -108,6 +108,19 @@ vi.mock('@/lib/utils', () => ({ cn: (...args: unknown[]) => args.filter(Boolean) type R = ReactTestRenderer; type I = ReactTestInstance; +function fullCapabilities(overrides: Record = {}): Record { + return { + chatMessages: { available: true, unavailableReason: null }, + agentAttention: { available: true, unavailableReason: null }, + agentUpdates: { available: true, unavailableReason: null }, + sessionStatus: { available: true, unavailableReason: null }, + kiloclawActivity: { available: true, unavailableReason: null }, + balanceAlerts: { available: true, unavailableReason: null }, + securityFindings: { available: true, unavailableReason: null }, + ...overrides, + }; +} + function fullPrefs(overrides: Record = {}): Record { return { chatMessages: true, @@ -119,6 +132,7 @@ function fullPrefs(overrides: Record = {}): Record void) return sw ? (sw.props as { onValueChange?: (value: boolean) => void }).onValueChange : undefined; } +function textWithChildren(root: I, content: string): I[] { + return root.findAll( + n => typeof n.type === 'string' && (n.type as string) === 'Text' && n.props.children === content + ); +} + // The switch renders as soon as the preference query resolves, but it stays // disabled until the master gate settles (the device-token query is gated on // the permission query, so it settles one cascade later). Wait for the switch @@ -319,3 +339,121 @@ describe('NotificationsScreen KiloClaw activity row', () => { expect(skeletonCount(renderer.root)).toBeGreaterThan(0); }); }); + +describe('NotificationsScreen category availability', () => { + beforeEach(() => { + vi.clearAllMocks(); + useKiloClawTabVisible.mockReturnValue(true); + getNotificationPermissionStatus.mockResolvedValue('granted'); + getDevicePushToken.mockResolvedValue('device-token'); + pushTokensQueryFn.mockResolvedValue([{ token: 'device-token', platform: 'android' }]); + setPreferenceMutationFn.mockResolvedValue({}); + registerTokenMutationFn.mockResolvedValue({ success: true }); + }); + + it('happy: an available category toggle flips and persists', async () => { + prefsQueryFn.mockResolvedValue(fullPrefs()); + const { renderer } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + expect(switchesByLabel(renderer.root, 'Chat messages')[0]?.props.value).toBe(true); + + prefsQueryFn.mockResolvedValue(fullPrefs({ chatMessages: false })); + act(() => { + switchOnValueChange(renderer.root, 'Chat messages')?.(false); + }); + await waitFor(() => setPreferenceMutationFn.mock.calls.length === 1); + expect(setPreferenceMutationFn.mock.calls[0]?.[0]).toEqual({ chatMessages: false }); + await waitFor(() => switchesByLabel(renderer.root, 'Chat messages')[0]?.props.value === false); + expect(toastError).not.toHaveBeenCalled(); + }); + + it('non-retryable unhappy: an unavailable category disables the switch and shows the server reason', async () => { + prefsQueryFn.mockResolvedValue( + fullPrefs({ + capabilities: fullCapabilities({ + balanceAlerts: { + available: false, + unavailableReason: 'Join an organization to get balance alerts.', + }, + }), + }) + ); + const { renderer } = await renderScreen(); + + // Wait for an available sibling row to be enabled first: the master gate + // disables every row until the permission/device-token/push-token queries + // settle, so the unavailable row is disabled from the first render. Waiting + // on the sibling proves the gate settled and capabilities loaded, so the + // Balance alerts `disabled` below is the unavailable state, not the gate. + await waitForEnabledSwitch(renderer, 'Chat messages'); + + expect(switchesByLabel(renderer.root, 'Balance alerts')[0]?.props.disabled).toBe(true); + expect( + textWithChildren(renderer.root, 'Join an organization to get balance alerts.').length + ).toBe(1); + }); + + it('retryable unhappy: a category save failure rolls back the optimistic flip', async () => { + prefsQueryFn.mockResolvedValue(fullPrefs()); + setPreferenceMutationFn.mockRejectedValue({ + data: { code: 'INTERNAL_SERVER_ERROR' }, + message: 'boom', + }); + const { renderer } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + act(() => { + switchOnValueChange(renderer.root, 'Chat messages')?.(false); + }); + await waitFor(() => setPreferenceMutationFn.mock.calls.length === 1); + await waitFor(() => activityIndicators(renderer.root).length === 0); + + expect(switchesByLabel(renderer.root, 'Chat messages')[0]?.props.value).toBe(true); + expect(toastError).toHaveBeenCalledWith('boom'); + }); + + it('happy: a preferences response without capabilities renders every row as available', async () => { + prefsQueryFn.mockResolvedValue({ + chatMessages: true, + agentAttention: true, + agentUpdates: true, + sessionStatus: true, + kiloclawActivity: true, + balanceAlerts: true, + securityFindings: true, + agentPushEnabled: true, + notificationPreviews: 'generic', + }); + const { renderer } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + expect(switchesByLabel(renderer.root, 'Chat messages')[0]?.props.disabled).toBe(false); + expect(switchesByLabel(renderer.root, 'Balance alerts')[0]?.props.disabled).toBe(false); + expect(switchesByLabel(renderer.root, 'KiloClaw activity')[0]?.props.disabled).toBe(false); + }); + + it('retryable unhappy: a capabilities query failure keeps last good availability and shows retry', async () => { + prefsQueryFn.mockResolvedValue(fullPrefs()); + const { renderer, queryClient } = await renderScreen(); + await waitForEnabledSwitch(renderer, 'Chat messages'); + + prefsQueryFn.mockRejectedValue(new Error('prefs boom')); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ['getNotificationPreferences'] }); + }); + + await waitFor( + () => + renderer.root.findAll( + n => + typeof n.type === 'string' && + (n.type as string) === 'Pressable' && + n.props.accessibilityLabel === 'Retry loading notification categories' + ).length === 1 + ); + // Last good availability is preserved: rows stay rendered and enabled. + expect(switchesByLabel(renderer.root, 'Chat messages')[0]?.props.disabled).toBe(false); + expect(switchesByLabel(renderer.root, 'Balance alerts')[0]?.props.disabled).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/notifications-screen.tsx b/apps/mobile/src/components/notifications-screen.tsx index 86e198d7e3..71f26f4d5a 100644 --- a/apps/mobile/src/components/notifications-screen.tsx +++ b/apps/mobile/src/components/notifications-screen.tsx @@ -138,11 +138,18 @@ const CATEGORY_META: readonly CategoryMeta[] = [ }, ] as const; +/** Per-category availability from the preferences response `capabilities` map. */ +type NotificationCategoryCapability = Readonly<{ + available: boolean; + unavailableReason: string | null; +}>; + type CategoryRowProps = Readonly<{ meta: CategoryMeta; queryKey: readonly unknown[]; queryClient: ReturnType; preferences: NotificationPreferences | undefined; + capability: NotificationCategoryCapability | undefined; disabled: boolean; isPending: boolean; onChange: (next: boolean) => void; @@ -153,6 +160,7 @@ function CategoryRow({ queryKey, queryClient, preferences, + capability, disabled, isPending, onChange, @@ -166,7 +174,12 @@ function CategoryRow({ ? readAgentPushPreference(queryClient, queryKey, meta.key) : (preferences?.[meta.key] ?? readAgentPushPreference(queryClient, queryKey, meta.key)); const editable = deriveAgentPushEditable({ hasData: preferences != null, isPending }); - const isDisabled = disabled || !editable; + // An unavailable category is a terminal, non-retryable state: the switch is + // disabled and the server reason replaces the subtitle. A missing entry (the + // `noUncheckedIndexedAccess` widening) defaults to available. + const unavailable = capability?.available === false; + const isDisabled = disabled || !editable || unavailable; + const subtitle = unavailable ? (capability.unavailableReason ?? meta.subtitle) : meta.subtitle; return ( @@ -178,7 +191,7 @@ function CategoryRow({ {meta.title} - {meta.subtitle} + {subtitle} {isPending && } @@ -632,6 +645,16 @@ export function NotificationsScreen() { queryKey={preferencesQueryKey} queryClient={queryClient} preferences={preferences} + capability={ + // The server type marks `capabilities` required, but a + // backend that predates the field returns none. The guard + // keeps the old response on the always-available path. + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition + preferences.capabilities?.[meta.key] ?? { + available: true, + unavailableReason: null, + } + } disabled={!notificationsEnabled} isPending={pendingCategories.has(meta.key)} onChange={next => { diff --git a/apps/mobile/src/components/organization/invited-member-row-state.test.ts b/apps/mobile/src/components/organization/invited-member-row-state.test.ts index e8a62a4be4..badb2e267e 100644 --- a/apps/mobile/src/components/organization/invited-member-row-state.test.ts +++ b/apps/mobile/src/components/organization/invited-member-row-state.test.ts @@ -72,7 +72,7 @@ describe('INVITE_SUCCESS_MESSAGE', () => { describe('invitedMemberActionOptions', () => { it('offers a Resend invite option for a failed invite', () => { expect(canResendInvite('failed')).toBe(true); - expect(invitedMemberActionOptions('failed')).toEqual([ + expect(invitedMemberActionOptions('failed', true)).toEqual([ 'Share invite link', 'Resend invite', 'Revoke invitation', @@ -83,13 +83,22 @@ describe('invitedMemberActionOptions', () => { it('omits the Resend invite option for non-failed statuses', () => { for (const status of ['pending', 'sending', 'delivered', null] as const) { expect(canResendInvite(status)).toBe(false); - expect(invitedMemberActionOptions(status)).toEqual([ + expect(invitedMemberActionOptions(status, true)).toEqual([ 'Share invite link', 'Revoke invitation', 'Cancel', ]); } }); + + it('omits the Share invite link option when the caller has no invite URL', () => { + expect(invitedMemberActionOptions('failed', false)).toEqual([ + 'Resend invite', + 'Revoke invitation', + 'Cancel', + ]); + expect(invitedMemberActionOptions('delivered', false)).toEqual(['Revoke invitation', 'Cancel']); + }); }); describe('useResendInvite', () => { diff --git a/apps/mobile/src/components/organization/invited-member-row-state.ts b/apps/mobile/src/components/organization/invited-member-row-state.ts index 63d25884b2..7745c9b112 100644 --- a/apps/mobile/src/components/organization/invited-member-row-state.ts +++ b/apps/mobile/src/components/organization/invited-member-row-state.ts @@ -49,11 +49,17 @@ export function canResendInvite(emailStatus: InvitedOrgMember['emailStatus']): b /** * Action-sheet options for an invited member row, in display order. The - * `Resend invite` option appears only for a failed invite. + * `Resend invite` option appears only for a failed invite. The `Share invite + * link` option appears only when the caller has the invite URL: a member + * caller's `withMembers` response omits `inviteUrl`, so the row must not offer + * a share action it cannot perform. */ -export function invitedMemberActionOptions(emailStatus: InvitedOrgMember['emailStatus']): string[] { +export function invitedMemberActionOptions( + emailStatus: InvitedOrgMember['emailStatus'], + hasInviteUrl: boolean +): string[] { return [ - 'Share invite link', + ...(hasInviteUrl ? ['Share invite link'] : []), ...(canResendInvite(emailStatus) ? ['Resend invite'] : []), 'Revoke invitation', 'Cancel', diff --git a/apps/mobile/src/components/organization/invited-member-row.tsx b/apps/mobile/src/components/organization/invited-member-row.tsx index 463af7b1a3..e4b09cd6c7 100644 --- a/apps/mobile/src/components/organization/invited-member-row.tsx +++ b/apps/mobile/src/components/organization/invited-member-row.tsx @@ -57,7 +57,8 @@ export function InvitedMemberRow({ } function openActions() { - const options = invitedMemberActionOptions(invite.emailStatus); + const hasInviteUrl = 'inviteUrl' in invite; + const options = invitedMemberActionOptions(invite.emailStatus, hasInviteUrl); showActionSheetWithOptions( { options, @@ -68,7 +69,9 @@ export function InvitedMemberRow({ index => { const label = index !== undefined ? options[index] : undefined; if (label === 'Share invite link') { - void Share.share({ message: invite.inviteUrl }); + if ('inviteUrl' in invite) { + void Share.share({ message: invite.inviteUrl }); + } } else if (label === 'Resend invite') { resendInvite.mutate({ inviteId: invite.inviteId }); } else if (label === 'Revoke invitation') { diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx index a4b3c96b97..96906ce7bc 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -15,6 +15,8 @@ import { Text } from '@/components/ui/text'; import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; import { type ActiveOrgMember, + isActiveOrgMember, + type OrgMember, useOrgBoundary, useOrgWithMembers, } from '@/lib/hooks/use-organization-queries'; @@ -96,8 +98,9 @@ function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormPr export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { const { organizationId, role, org, isResolving } = useOrgBoundary(); const orgWithMembers = useOrgWithMembers(organizationId); - const member = orgWithMembers.data?.members.find( - (m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId + const members: OrgMember[] = orgWithMembers.data?.members ?? []; + const member = members.find( + (m): m is ActiveOrgMember => isActiveOrgMember(m) && m.id === memberId ); if (isResolving || orgWithMembers.isLoading) { diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index 37d54789ae..0540a1cd17 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -18,6 +18,8 @@ import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { type ActiveOrgMember, type InvitedOrgMember, + isActiveOrgMember, + isInvitedOrgMember, isMoneyRole, useOrgBoundary, useOrgWithMembers, @@ -70,12 +72,9 @@ export function OrganizationMembersScreen() { const { userId: currentUserId } = useCurrentUserId(); const paddingBottom = useTabBarBottomPadding(); - const activeMembers = sortActiveMembers( - orgWithMembers.data?.members.filter(m => m.status === 'active') ?? [] - ); - const invitedMembers = sortInvitedMembers( - orgWithMembers.data?.members.filter(m => m.status === 'invited') ?? [] - ); + const members = orgWithMembers.data?.members ?? []; + const activeMembers = sortActiveMembers(members.filter(m => isActiveOrgMember(m))); + const invitedMembers = sortInvitedMembers(members.filter(m => isInvitedOrgMember(m))); const items = useMemo( () => buildMembersListItems({ activeMembers, invitedMembers }), diff --git a/apps/mobile/src/components/security-agent/audit-report-button.tsx b/apps/mobile/src/components/security-agent/audit-report-button.tsx index b3f25f2106..66dc1d6683 100644 --- a/apps/mobile/src/components/security-agent/audit-report-button.tsx +++ b/apps/mobile/src/components/security-agent/audit-report-button.tsx @@ -1,25 +1,25 @@ -import { getSecurityAgentAuditUrl } from '@kilocode/app-shared/security-agent'; +import { useRouter } from 'expo-router'; import { MoreHorizontal } from '@/components/ui/icons'; import { Pressable } from 'react-native'; -import { WEB_BASE_URL } from '@/lib/config'; -import { openExternalUrl } from '@/lib/external-link'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getSecurityAgentPath } from '@/lib/security-agent'; + +// Compatibility: external web report URL kept for the web client and app versions before the native report; remove when the minimum supported app version ships the native report. /** - * Header action that opens the web audit report directly — shared by the + * Header action that opens the native audit report — shared by the * dashboard, scope-entry, and settings-overview screens, all of which show * it only when the viewer can manage Security Agent for this scope. */ export function AuditReportButton({ scope }: Readonly<{ scope: string }>) { + const router = useRouter(); const colors = useThemeColors(); return ( { - void openExternalUrl(getSecurityAgentAuditUrl(WEB_BASE_URL, scope), { - label: 'audit report', - }); + router.push(getSecurityAgentPath(scope, 'audit-report')); }} accessibilityRole="button" accessibilityLabel="View audit report" diff --git a/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx new file mode 100644 index 0000000000..5b3110229e --- /dev/null +++ b/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx @@ -0,0 +1,323 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom); its React 19 deprecation notice points to the DOM-based Testing Library, which cannot render this app's non-DOM tree. */ + +// Audit-report screen state contract: loading shows a skeleton; a network +// error and a `query_failed` response are retryable (inline error + Retry); +// the org billing-gate denial (FORBIDDEN/UNAUTHORIZED) is non-retryable with +// an explanation and no Retry; an empty period shows EmptyState. The screen +// branches personal vs. org on the tRPC procedure, mirroring +// use-security-agent.ts. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AuditReportScreen } from './audit-report-screen'; + +const personalQueryOptions = vi.hoisted(() => vi.fn()); +const orgQueryOptions = vi.hoisted(() => vi.fn()); +const useQuery = vi.hoisted(() => vi.fn()); + +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: 'ScrollView', +})); +vi.mock('@/components/ui/icons', () => ({ + FileText: 'FileText', + ShieldOff: 'ShieldOff', +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + securityAgent: { getAuditReport: { queryOptions: personalQueryOptions } }, + organizations: { securityAgent: { getAuditReport: { queryOptions: orgQueryOptions } } }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery, +})); +// Faithful mirror of the real classifier (covered by its own suite): only the +// literal 'personal' scope is personal. +vi.mock('@kilocode/app-shared/security-agent', () => ({ + isPersonalSecurityScope: (scope: string) => scope === 'personal', +})); +vi.mock('@/lib/utils', () => ({ + capitalize: (value: string) => value.charAt(0).toUpperCase() + value.slice(1), + formatDate: String, + parseTimestamp: (value: unknown) => value, +})); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/security-agent/collapsible-section', () => ({ + CollapsibleSection: 'CollapsibleSection', +})); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/tab-screen', () => ({ + TabScreenScrollView: (props: { children?: unknown }) => props.children, +})); + +type R = TestRenderer.ReactTestRenderer; +type I = TestRenderer.ReactTestInstance; + +const FINDING = { + findingId: 'f1', + source: 'dependabot', + sourceId: null, + repository: 'org/repo', + title: 'Prototype pollution in lodash', + severity: 'high', + status: 'open', + packageName: 'lodash', + packageEcosystem: 'npm', + manifestPath: 'package.json', + patchedVersion: null, + ghsaId: null, + cveId: null, + cweIds: [], + cvssScore: null, + dependabotUrl: null, + firstDetectedAt: '2026-01-01T00:00:00.000Z', + canonicalFindingId: null, + deleted: false, + sla: { status: 'unknown', deadline: null, reason: 'missing_recorded_deadline' }, + hasLegacySupplementalActivity: false, + events: [ + { + id: 'e1', + action: 'security.finding.created', + label: 'Imported', + occurredAt: '2026-01-01T00:00:00.000Z', + sourceOccurredAt: null, + recordedAt: '2026-01-01T00:00:00.000Z', + actor: { type: 'system', displayName: 'Kilo system', masked: false }, + beforeState: null, + afterState: null, + metadata: null, + legacySupplemental: false, + }, + ], +}; + +function makeReport(overrides: Record = {}) { + return { + reportVersion: 1, + owner: { type: 'user', id: 'u1', displayName: 'Personal owner' }, + period: { + start: '2026-01-01T00:00:00.000Z', + endExclusive: '2026-01-02T00:00:00.000Z', + displayEnd: '2026-01-01', + timeZone: 'UTC', + }, + generatedAt: '2026-01-02T00:00:00.000Z', + dataThrough: '2026-01-02T00:00:00.000Z', + reliableCoverageStart: '2025-01-01T00:00:00.000Z', + evidenceBasis: 'recorded_by_kilo', + hasLegacySupplementalActivity: false, + summary: { + findingCount: 1, + activityCount: 1, + bySeverity: { critical: 0, high: 1, medium: 0, low: 0 }, + byAction: {}, + }, + findings: [FINDING], + ...overrides, + }; +} + +function setQueryState(state: { + isLoading?: boolean; + isError?: boolean; + isPending?: boolean; + isPaused?: boolean; + error?: unknown; + data?: unknown; +}) { + useQuery.mockReturnValue({ + isLoading: false, + isError: false, + isPending: false, + isPaused: false, + error: null, + data: undefined, + refetch: vi.fn(), + ...state, + }); +} + +function renderScreen(scope: string): R { + const ref: { current: R | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(AuditReportScreen, { scope })); + }); + const r = ref.current; + if (!r) { + throw new Error('renderer was not created'); + } + return r; +} + +function findByType(root: I, type: string): I[] { + return root.findAll(n => typeof n.type === 'string' && (n.type as string) === type); +} + +function isInstance(child: I | string): child is I { + return typeof child !== 'string'; +} + +// The renderer keeps the top function component as `root.root`; its first +// child is the screen's root View, whose first child must be the header. +function firstChildTypeOfScreenRoot(root: I): string | undefined { + const screenView = root.children.find(isInstance); + const first = screenView?.children.find(isInstance); + if (!first) { + return undefined; + } + const type = first.type; + return typeof type === 'string' ? type : undefined; +} + +function useQueryEnabledFlags(): boolean[] { + return useQuery.mock.calls.map(call => (call[0] as { enabled?: boolean }).enabled === true); +} + +describe('AuditReportScreen states', () => { + beforeEach(() => { + personalQueryOptions.mockClear(); + orgQueryOptions.mockClear(); + useQuery.mockClear(); + useQuery.mockReset(); + }); + + it('renders the ScreenHeader as the first child', () => { + setQueryState({ isLoading: true }); + const root = renderScreen('personal'); + + expect(firstChildTypeOfScreenRoot(root.root)).toBe('ScreenHeader'); + }); + + it('renders a skeleton while loading', () => { + setQueryState({ isLoading: true }); + const root = renderScreen('personal'); + + expect(findByType(root.root, 'Skeleton').length).toBeGreaterThan(0); + expect(findByType(root.root, 'QueryError')).toHaveLength(0); + expect(findByType(root.root, 'EmptyState')).toHaveLength(0); + }); + + it('renders a retryable error with Retry on a network failure', () => { + setQueryState({ isError: true, error: { data: { code: 'INTERNAL_SERVER_ERROR' } } }); + const root = renderScreen('personal'); + + const errors = findByType(root.root, 'QueryError'); + expect(errors).toHaveLength(1); + expect(errors[0]?.props.message).toBe('Could not load the audit report'); + expect(typeof errors[0]?.props.onRetry).toBe('function'); + }); + + it('maps query_failed to a retryable error, not empty', () => { + setQueryState({ + data: { status: 'query_failed', message: 'Report query did not finish' }, + }); + const root = renderScreen('personal'); + + const errors = findByType(root.root, 'QueryError'); + expect(errors).toHaveLength(1); + expect(errors[0]?.props.message).toBe('Report query did not finish. Try again.'); + expect(typeof errors[0]?.props.onRetry).toBe('function'); + expect(findByType(root.root, 'EmptyState')).toHaveLength(0); + }); + + it('renders a retryable offline error on a paused initial fetch', () => { + setQueryState({ isPending: true, isPaused: true }); + const root = renderScreen('personal'); + + const errors = findByType(root.root, 'QueryError'); + expect(errors).toHaveLength(1); + expect(errors[0]?.props.variant).toBe('offline'); + expect(errors[0]?.props.message).toBe('Check your connection and try again.'); + expect(typeof errors[0]?.props.onRetry).toBe('function'); + expect(findByType(root.root, 'Skeleton')).toHaveLength(0); + expect(findByType(root.root, 'EmptyState')).toHaveLength(0); + }); + + it('renders a non-retryable explanation without Retry on FORBIDDEN', () => { + setQueryState({ isError: true, error: { data: { code: 'FORBIDDEN' } } }); + const root = renderScreen('org-123'); + + const empty = findByType(root.root, 'EmptyState'); + expect(empty).toHaveLength(1); + expect(empty[0]?.props.title).toBe('Audit report unavailable'); + expect(findByType(root.root, 'QueryError')).toHaveLength(0); + }); + + it('treats the org billing-gate UNAUTHORIZED denial as non-retryable too', () => { + setQueryState({ isError: true, error: { data: { code: 'UNAUTHORIZED' } } }); + const root = renderScreen('org-123'); + + const empty = findByType(root.root, 'EmptyState'); + expect(empty).toHaveLength(1); + expect(empty[0]?.props.title).toBe('Audit report unavailable'); + expect(findByType(root.root, 'QueryError')).toHaveLength(0); + }); + + it('treats a personal UNAUTHORIZED as a retryable session error', () => { + setQueryState({ isError: true, error: { data: { code: 'UNAUTHORIZED' } } }); + const root = renderScreen('personal'); + + const errors = findByType(root.root, 'QueryError'); + expect(errors).toHaveLength(1); + expect(errors[0]?.props.message).toBe('Could not load the audit report'); + expect(typeof errors[0]?.props.onRetry).toBe('function'); + expect(findByType(root.root, 'EmptyState')).toHaveLength(0); + }); + + it('renders EmptyState for an empty period', () => { + setQueryState({ + data: { + status: 'ok', + report: makeReport({ findings: [], summary: { findingCount: 0, activityCount: 0 } }), + }, + }); + const root = renderScreen('personal'); + + const empty = findByType(root.root, 'EmptyState'); + expect(empty).toHaveLength(1); + expect(empty[0]?.props.title).toBe('No recorded activity'); + }); + + it('renders one section per finding group for a non-empty report', () => { + setQueryState({ data: { status: 'ok', report: makeReport() } }); + const root = renderScreen('personal'); + + expect(findByType(root.root, 'CollapsibleSection')).toHaveLength(1); + expect(findByType(root.root, 'EmptyState')).toHaveLength(0); + expect(findByType(root.root, 'QueryError')).toHaveLength(0); + }); +}); + +describe('AuditReportScreen personal/org branching', () => { + beforeEach(() => { + personalQueryOptions.mockClear(); + orgQueryOptions.mockClear(); + useQuery.mockClear(); + useQuery.mockReset(); + }); + + it('calls the personal procedure (enabled) for the personal scope', () => { + setQueryState({ isLoading: true }); + renderScreen('personal'); + + expect(personalQueryOptions).toHaveBeenCalledWith({}); + expect(orgQueryOptions).toHaveBeenCalledWith({ organizationId: 'personal' }); + expect(useQueryEnabledFlags()).toEqual([true, false]); + }); + + it('calls the org procedure (enabled) for an organization scope', () => { + setQueryState({ isLoading: true }); + renderScreen('org-123'); + + expect(personalQueryOptions).toHaveBeenCalledWith({}); + expect(orgQueryOptions).toHaveBeenCalledWith({ organizationId: 'org-123' }); + expect(useQueryEnabledFlags()).toEqual([false, true]); + }); +}); diff --git a/apps/mobile/src/components/security-agent/audit-report-screen.tsx b/apps/mobile/src/components/security-agent/audit-report-screen.tsx new file mode 100644 index 0000000000..783756fc0b --- /dev/null +++ b/apps/mobile/src/components/security-agent/audit-report-screen.tsx @@ -0,0 +1,209 @@ +import { isPersonalSecurityScope } from '@kilocode/app-shared/security-agent'; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { useQuery } from '@tanstack/react-query'; +import { FileText, ShieldOff } from '@/components/ui/icons'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { CollapsibleSection } from '@/components/security-agent/collapsible-section'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { TabScreenScrollView } from '@/components/tab-screen'; +import { useTRPC } from '@/lib/trpc'; +import { capitalize, formatDate, parseTimestamp } from '@/lib/utils'; + +type RouterOutputs = inferRouterOutputs; +type AuditReportResponse = RouterOutputs['securityAgent']['getAuditReport']; +type SecurityAgentAuditReport = Extract['report']; +type SecurityFindingAuditSection = SecurityAgentAuditReport['findings'][number]; + +const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'] as const; + +// Personal and org procedures resolve to nominally distinct tRPC option +// types even when structurally identical, so we always call both hooks (one +// disabled) and return whichever is active — the same branching pattern as +// use-security-agent.ts. +function useSecurityAgentAuditReport(scope: string) { + const trpc = useTRPC(); + const personal = useQuery({ + ...trpc.securityAgent.getAuditReport.queryOptions({}), + enabled: isPersonalSecurityScope(scope), + }); + const organization = useQuery({ + ...trpc.organizations.securityAgent.getAuditReport.queryOptions({ organizationId: scope }), + enabled: !isPersonalSecurityScope(scope), + }); + return isPersonalSecurityScope(scope) ? personal : organization; +} + +function AuditReportSkeleton() { + return ( + + + + + + + + ); +} + +function ReportHeader({ report }: Readonly<{ report: SecurityAgentAuditReport }>) { + const start = formatDate(parseTimestamp(report.period.start)); + const end = formatDate(parseTimestamp(report.period.displayEnd)); + const generatedAt = formatDate(parseTimestamp(report.generatedAt)); + + return ( + + {report.owner.displayName} + + {start} – {end} · UTC + + + Generated {generatedAt} + + + ); +} + +function SummaryCount({ label, value }: Readonly<{ label: string; value: number }>) { + return ( + + {value} + + {label} + + + ); +} + +function ReportSummary({ report }: Readonly<{ report: SecurityAgentAuditReport }>) { + return ( + + Report summary + + + + {SEVERITY_ORDER.map(severity => ( + + ))} + + + ); +} + +function FindingSection({ finding }: Readonly<{ finding: SecurityFindingAuditSection }>) { + const meta = [capitalize(finding.severity), finding.repository ?? 'Repository not recorded'].join( + ' · ' + ); + + return ( + + + {meta} + + + {finding.events.map(event => ( + + {event.label} + + {formatDate(parseTimestamp(event.occurredAt))} · {event.actor.displayName} + + + ))} + + + ); +} + +function AuditReportView({ report }: Readonly<{ report: SecurityAgentAuditReport }>) { + if (report.findings.length === 0) { + const start = formatDate(parseTimestamp(report.period.start)); + const end = formatDate(parseTimestamp(report.period.displayEnd)); + return ( + + ); + } + + return ( + + + + {report.findings.map(finding => ( + + ))} + + ); +} + +export function AuditReportScreen({ scope }: Readonly<{ scope: string }>) { + const query = useSecurityAgentAuditReport(scope); + const errorCode = query.error?.data?.code; + // The org procedure is `organizationBillingProcedure`, which rejects + // viewers without the owner/billing_manager role. That denial is + // non-retryable: retrying cannot change the viewer's role. + const forbidden = + !isPersonalSecurityScope(scope) && + query.isError && + (errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED'); + + return ( + + + + {query.isLoading && } + + {forbidden && ( + + )} + + {!query.isLoading && query.isError && !forbidden && ( + + void query.refetch()} + /> + + )} + + {!query.isLoading && !query.isError && query.data?.status === 'query_failed' && ( + + void query.refetch()} + /> + + )} + + {query.isPending && query.isPaused && ( + + void query.refetch()} + /> + + )} + + {!query.isLoading && !query.isError && query.data?.status === 'ok' && ( + + )} + + ); +} diff --git a/apps/mobile/src/components/security-agent/automation-settings-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/automation-settings-screen.mounted.test.tsx new file mode 100644 index 0000000000..ab4a6cb438 --- /dev/null +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.mounted.test.tsx @@ -0,0 +1,181 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// Automation-settings approval-gate contract: the "Require approval before +// auto-remediation" toggle hydrates from the loaded config, persists through +// the save patch object, and renders disabled for read-only viewers. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AutomationSettingsScreen } from './automation-settings-screen'; + +const config = vi.hoisted(() => ({ + data: null as unknown, + isLoading: false, + isError: false, + refetch: vi.fn(), +})); +const capability = vi.hoisted(() => ({ + canManage: true, +})); +const save = vi.hoisted(() => ({ + mutateAsync: vi.fn(), + isPending: false, +})); +const trackInteraction = vi.hoisted(() => ({ + mutate: vi.fn(), +})); + +const toggleRows = vi.hoisted(() => ({ + rows: [] as { + title: string; + value: boolean; + disabled: boolean; + onValueChange: (value: boolean) => void; + }[], +})); +const saveButton = vi.hoisted(() => ({ + onSave: null as (() => Promise) | null, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Alert: { alert: vi.fn() }, +})); +vi.mock('sonner-native', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); +vi.mock('@kilocode/app-shared/security-agent', () => ({ + getSettingsDirtyState: () => 'clean', +})); +vi.mock('@/lib/hooks/use-security-agent', () => ({ + useSecurityAgentCapability: () => capability, + useSecurityAgentConfig: () => config, + useSaveSecurityAgentConfig: () => save, + useTrackSecurityAgentInteraction: () => trackInteraction, +})); +vi.mock('@/lib/hooks/use-settings-back-guard', () => ({ + useSecurityAgentSettingsRedirect: () => undefined, + useSettingsBackGuard: () => ({ onBack: () => undefined, skipNextGuardRef: { current: false } }), +})); +vi.mock('@/components/security-agent/settings-pill-group', () => ({ + PillGroup: () => null, +})); +vi.mock('@/components/security-agent/settings-save-button', () => ({ + SettingsSaveButton: (props: { onSave: () => Promise }) => { + saveButton.onSave = props.onSave; + return null; + }, +})); +vi.mock('@/components/security-agent/settings-toggle-row', () => ({ + ToggleRow: (props: { + title: string; + value: boolean; + disabled: boolean; + onValueChange: (value: boolean) => void; + }) => { + toggleRows.rows.push(props); + return null; + }, +})); +vi.mock('@/components/platform-error-screen', () => ({ PlatformErrorScreen: () => null })); +vi.mock('@/components/screen-header', () => ({ + ScreenHeader: (props: { headerRight?: unknown }) => props.headerRight ?? null, +})); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/tab-screen', () => ({ + TabScreenScrollView: (props: { children?: unknown }) => props.children, +})); + +const APPROVAL_ROW_TITLE = 'Require approval before auto-remediation'; + +function enabledConfig(overrides: Record = {}): Record { + return { + isEnabled: true, + autoAnalysisEnabled: false, + autoAnalysisMinSeverity: 'high', + autoAnalysisIncludeExisting: false, + autoRemediationEnabled: true, + autoRemediationMinSeverity: 'high', + autoRemediationIncludeExisting: false, + autoRemediationRequireApproval: true, + autoDismissEnabled: false, + autoDismissConfidenceThreshold: 'high', + ...overrides, + }; +} + +function renderScreen(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + createElement(AutomationSettingsScreen, { scope: 'personal' }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function approvalRow(): { + title: string; + value: boolean; + disabled: boolean; + onValueChange: (value: boolean) => void; +} { + const row = toggleRows.rows.find(r => r.title === APPROVAL_ROW_TITLE); + if (!row) { + throw new Error('approval toggle row not found'); + } + return row; +} + +describe('AutomationSettingsScreen approval gate', () => { + beforeEach(() => { + config.data = null; + config.isLoading = false; + config.isError = false; + capability.canManage = true; + save.isPending = false; + save.mutateAsync.mockReset(); + save.mutateAsync.mockResolvedValue({}); + trackInteraction.mutate.mockClear(); + toggleRows.rows = []; + saveButton.onSave = null; + }); + + it('hydrates the approval toggle from the loaded config', () => { + config.data = enabledConfig({ autoRemediationRequireApproval: false }); + renderScreen(); + + expect(approvalRow().value).toBe(false); + }); + + it('persists the approval toggle through the save patch object', async () => { + config.data = enabledConfig({ autoRemediationRequireApproval: true }); + renderScreen(); + + act(() => { + approvalRow().onValueChange(false); + }); + await act(async () => { + await saveButton.onSave?.(); + }); + + expect(save.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ autoRemediationRequireApproval: false }) + ); + }); + + it('renders the approval toggle disabled for read-only viewers', () => { + capability.canManage = false; + config.data = enabledConfig(); + renderScreen(); + + expect(approvalRow().disabled).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx index 73b3afe976..4c064f0472 100644 --- a/apps/mobile/src/components/security-agent/automation-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/automation-settings-screen.tsx @@ -1,6 +1,6 @@ import { getSettingsDirtyState } from '@kilocode/app-shared/security-agent'; import { useEffect, useRef, useState } from 'react'; -import { View } from 'react-native'; +import { Alert, View } from 'react-native'; import { toast } from 'sonner-native'; import { PillGroup } from '@/components/security-agent/settings-pill-group'; @@ -66,6 +66,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) const [autoRemediationEnabled, setAutoRemediationEnabled] = useState(false); const [autoRemediationMinSeverity, setAutoRemediationMinSeverity] = useState('all'); const [autoRemediationIncludeExisting, setAutoRemediationIncludeExisting] = useState(false); + const [autoRemediationRequireApproval, setAutoRemediationRequireApproval] = useState(true); const [autoDismissEnabled, setAutoDismissEnabled] = useState(false); const [autoDismissConfidenceThreshold, setAutoDismissConfidenceThreshold] = useState('high'); @@ -87,6 +88,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) setAutoRemediationEnabled(config.data.autoRemediationEnabled); setAutoRemediationMinSeverity(config.data.autoRemediationMinSeverity); setAutoRemediationIncludeExisting(config.data.autoRemediationIncludeExisting); + setAutoRemediationRequireApproval(config.data.autoRemediationRequireApproval); setAutoDismissEnabled(config.data.autoDismissEnabled); setAutoDismissConfidenceThreshold(config.data.autoDismissConfidenceThreshold); }, [config.data]); @@ -117,6 +119,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) autoRemediationEnabled, autoRemediationMinSeverity, autoRemediationIncludeExisting, + autoRemediationRequireApproval, autoDismissEnabled, autoDismissConfidenceThreshold, }; @@ -134,6 +137,28 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) } }; + // Enabling auto-remediation is destructive: it opens PRs without a human in + // the loop, so confirm before committing (apps/mobile/AGENTS.md rule). + const handleAutoRemediationToggle = (next: boolean) => { + if (!next) { + setAutoRemediationEnabled(false); + return; + } + Alert.alert( + 'Enable auto-remediation?', + 'Security Agent will open remediation PRs automatically for eligible exploitable findings.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Enable', + onPress: () => { + setAutoRemediationEnabled(true); + }, + }, + ] + ); + }; + const { onBack, skipNextGuardRef } = useSettingsBackGuard({ dirty, valid, onSave: handleSave }); if (config.isError && !config.data) { @@ -211,7 +236,7 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) subtitle="Automatically open PRs for eligible exploitable findings." value={autoRemediationEnabled} disabled={!canManage} - onValueChange={setAutoRemediationEnabled} + onValueChange={handleAutoRemediationToggle} /> {autoRemediationEnabled && ( <> @@ -229,6 +254,13 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) disabled={!canManage} onValueChange={setAutoRemediationIncludeExisting} /> + )} diff --git a/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx new file mode 100644 index 0000000000..131ab67e6b --- /dev/null +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx @@ -0,0 +1,312 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// Finding-remediation progress timeline: the panel renders the ordered +// remediation audit events (queued → pr_opened, or a terminal event) above the +// attempt history. A separately released client can talk to an old backend +// that omits `remediationTimeline`, so the panel treats a missing field as an +// empty list. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FindingRemediationPanel } from './finding-remediation-panel'; +import { type SecurityAnalysis } from '@/lib/security-agent'; + +const texts = vi.hoisted(() => ({ items: [] as string[] })); +const mocks = vi.hoisted(() => ({ + routerPush: vi.fn(), + prReviewEnabled: true, + openExternalUrl: vi.fn(), +})); + +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: vi.fn() }, +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: mocks.routerPush }), +})); +vi.mock('@/lib/analytics/posthog', () => ({ + FEATURE_FLAG_PR_REVIEW: 'mobile-pr-review', + useFeatureFlag: () => mocks.prReviewEnabled, +})); +vi.mock('@/lib/external-link', () => ({ + openExternalUrl: mocks.openExternalUrl, +})); +vi.mock('@/components/ui/icons', () => ({ + Wrench: 'Wrench', +})); +vi.mock('@/components/security-agent/collapsible-section', () => ({ + CollapsibleSection: (props: { children?: unknown }) => props.children ?? null, +})); +vi.mock('@/components/security-agent/finding-status-badge', () => ({ + FindingStatusBadge: () => null, +})); +vi.mock('@/components/empty-state', () => ({ EmptyState: () => null })); +vi.mock('@/components/query-error', () => ({ QueryError: () => null })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/kv-row', () => ({ KvRow: () => null })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); +vi.mock('@/components/ui/text', () => ({ + Text: (props: { children?: unknown }) => { + if (typeof props.children === 'string') { + texts.items.push(props.children); + } + return null; + }, +})); +vi.mock('@/lib/hooks/use-security-remediation', () => ({ + useStartSecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), + useRetrySecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), + useCancelSecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + primaryForeground: '#fff', + foreground: '#000', + mutedForeground: '#666', + }), +})); +vi.mock('@kilocode/app-shared/security-agent', () => ({ + formatRemediationOrigin: (origin: string) => origin, + formatValidationEvidenceEntry: () => '', + getRemediationStatusPresentation: () => ({ + label: 'Not started', + tone: 'neutral', + icon: 'clock', + spinning: false, + }), + getRemediationUnavailableCopy: () => null, +})); + +type R = TestRenderer.ReactTestRenderer; + +function analysisFixture(overrides: Record = {}): SecurityAnalysis { + return { + findingState: { status: 'open' }, + status: 'completed', + startedAt: null, + completedAt: null, + error: null, + analysis: null, + sessionId: null, + cliSessionId: null, + remediationSummary: null, + remediationCapability: { + canStart: false, + startReason: 'finding_not_open', + canRetry: false, + retryReason: 'finding_not_open', + canCancel: false, + cancelAttemptId: null, + }, + remediationAttempts: [], + remediationTimeline: [], + ...overrides, + } as unknown as SecurityAnalysis; +} + +function renderPanel(analysis: SecurityAnalysis): R { + const ref: { current: R | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + createElement(FindingRemediationPanel, { + scope: 'personal', + findingId: 'finding-1', + analysis, + isLoading: false, + isError: false, + onRetry: () => undefined, + }) + ); + }); + const r = ref.current; + if (!r) { + throw new Error('renderer was not created'); + } + return r; +} + +function pressButtons(r: R): void { + act(() => { + for (const node of r.root.findAll( + n => typeof n.type === 'string' && (n.type as string) === 'Button' + )) { + const onPress = node.props.onPress as (() => void) | undefined; + onPress?.(); + } + }); +} + +describe('FindingRemediationPanel remediation timeline', () => { + beforeEach(() => { + texts.items = []; + }); + + it('renders remediation timeline labels in order', () => { + renderPanel( + analysisFixture({ + remediationTimeline: [ + { action: 'security.remediation.queued', occurredAt: '2026-04-29T01:16:12.945Z' }, + { action: 'security.remediation.pr_opened', occurredAt: '2026-04-29T02:00:00.000Z' }, + ], + }) + ); + + const queuedIndex = texts.items.indexOf('Remediation requested'); + const prOpenedIndex = texts.items.indexOf('PR opened'); + expect(queuedIndex).toBeGreaterThanOrEqual(0); + expect(prOpenedIndex).toBeGreaterThan(queuedIndex); + }); + + it('renders terminal labels for failed, blocked, no_changes_needed, and cancelled', () => { + renderPanel( + analysisFixture({ + remediationTimeline: [ + { action: 'security.remediation.failed', occurredAt: '2026-04-29T01:00:00.000Z' }, + { action: 'security.remediation.blocked', occurredAt: '2026-04-29T01:01:00.000Z' }, + { + action: 'security.remediation.no_changes_needed', + occurredAt: '2026-04-29T01:02:00.000Z', + }, + { action: 'security.remediation.cancelled', occurredAt: '2026-04-29T01:03:00.000Z' }, + ], + }) + ); + + expect(texts.items).toContain('Remediation failed'); + expect(texts.items).toContain('Remediation blocked'); + expect(texts.items).toContain('No changes needed'); + expect(texts.items).toContain('Cancelled'); + }); + + it('renders nothing extra when the timeline is empty', () => { + renderPanel(analysisFixture({ remediationTimeline: [] })); + + expect(texts.items).not.toContain('Progress'); + expect(texts.items).not.toContain('Remediation requested'); + }); + + it('renders without throwing when the response omits remediationTimeline', () => { + const r = renderPanel(analysisFixture({ remediationTimeline: undefined })); + + expect(r.toJSON()).not.toBeNull(); + expect(texts.items).not.toContain('Progress'); + expect(texts.items).not.toContain('Remediation requested'); + }); +}); + +describe('FindingRemediationPanel pull request navigation', () => { + beforeEach(() => { + texts.items = []; + mocks.routerPush.mockReset(); + mocks.openExternalUrl.mockReset(); + mocks.prReviewEnabled = true; + }); + + it('navigates in-app for a github.com PR URL when the flag is on', () => { + const r = renderPanel( + analysisFixture({ + remediationSummary: { + status: 'pr_opened', + prUrl: 'https://github.com/kilo/kilo/pull/123', + prNumber: 123, + prDraft: false, + outcomeSummary: null, + }, + }) + ); + + pressButtons(r); + + expect(mocks.routerPush).toHaveBeenCalledWith('/(app)/pr-review/kilo/kilo/123'); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); + }); + + it('falls back to the browser when the flag is off', () => { + mocks.prReviewEnabled = false; + const r = renderPanel( + analysisFixture({ + remediationSummary: { + status: 'pr_opened', + prUrl: 'https://github.com/kilo/kilo/pull/123', + prNumber: 123, + prDraft: false, + outcomeSummary: null, + }, + }) + ); + + pressButtons(r); + + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://github.com/kilo/kilo/pull/123', { + label: 'pull request', + }); + }); + + it('falls back to the browser for a non-GitHub URL', () => { + const r = renderPanel( + analysisFixture({ + remediationSummary: { + status: 'pr_opened', + prUrl: 'https://gitlab.com/kilo/kilo/-/merge_requests/123', + prNumber: 123, + prDraft: false, + outcomeSummary: null, + }, + }) + ); + + pressButtons(r); + + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(mocks.openExternalUrl).toHaveBeenCalledWith( + 'https://gitlab.com/kilo/kilo/-/merge_requests/123', + { label: 'pull request' } + ); + }); + + it('routes both the summary and attempt buttons in-app', () => { + const r = renderPanel( + analysisFixture({ + remediationSummary: { + status: 'pr_opened', + prUrl: 'https://github.com/kilo/kilo/pull/123', + prNumber: 123, + prDraft: false, + outcomeSummary: null, + }, + remediationAttempts: [ + { + id: 'attempt-1', + attemptNumber: 1, + status: 'pr_opened', + prUrl: 'https://github.com/kilo/kilo/pull/456', + prNumber: 456, + prDraft: false, + origin: 'manual', + remediationModelSlug: 'gpt-5', + branchName: 'fix/thing', + updatedAt: '2026-04-29T02:00:00.000Z', + cancellationRequestedAt: null, + validationEvidence: [], + riskNotes: null, + draftReason: null, + blockedReason: null, + lastErrorRedacted: null, + }, + ], + }) + ); + + pressButtons(r); + + expect(mocks.routerPush).toHaveBeenCalledTimes(2); + expect(mocks.routerPush).toHaveBeenNthCalledWith(1, '/(app)/pr-review/kilo/kilo/123'); + expect(mocks.routerPush).toHaveBeenNthCalledWith(2, '/(app)/pr-review/kilo/kilo/456'); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx index f675bbd656..ed763780fb 100644 --- a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the panel composes the status card, remediation controls, summary PR button, timeline, and attempt history; each is a small rendered surface that mirrors the shared remediation pattern. Splitting would re-encode the same hooks. */ import { formatRemediationOrigin, formatValidationEvidenceEntry, @@ -5,7 +6,8 @@ import { getRemediationUnavailableCopy, } from '@kilocode/app-shared/security-agent'; import { Wrench } from '@/components/ui/icons'; -import { ActivityIndicator, Alert, Linking, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { ActivityIndicator, Alert, View } from 'react-native'; import { CollapsibleSection } from '@/components/security-agent/collapsible-section'; import { FindingStatusBadge } from '@/components/security-agent/finding-status-badge'; @@ -15,12 +17,16 @@ import { Button } from '@/components/ui/button'; import { KvRow } from '@/components/ui/kv-row'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog'; +import { resolveCodeReviewerOpenPrDestination } from '@/lib/code-reviewer-open-pr-destination'; +import { openExternalUrl } from '@/lib/external-link'; import { useCancelSecurityRemediation, useRetrySecurityRemediation, useStartSecurityRemediation, } from '@/lib/hooks/use-security-remediation'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getPrReviewPath } from '@/lib/profile-agent-navigation'; import { type SecurityAnalysis } from '@/lib/security-agent'; import { firstNonEmpty, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -33,6 +39,22 @@ type FindingRemediationPanelProps = { onRetry: () => void; }; +// Local label map for the remediation timeline events, keyed on the audit +// action values (same labels as the web audit report ACTION_LABELS). +const REMEDIATION_TIMELINE_LABELS = { + 'security.remediation.queued': 'Remediation requested', + 'security.remediation.pr_opened': 'PR opened', + 'security.remediation.failed': 'Remediation failed', + 'security.remediation.blocked': 'Remediation blocked', + 'security.remediation.no_changes_needed': 'No changes needed', + 'security.remediation.cancelled': 'Cancelled', +} satisfies Record; + +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} + // Ported from FindingDetailDialog.tsx:1849 (getRemediationPresentation) and // remediation-unavailable-copy.ts — capability/blocker, current summary, and // attempt history (already newest-first from the server) as plain facts. @@ -47,10 +69,21 @@ export function FindingRemediationPanel({ onRetry, }: Readonly) { const colors = useThemeColors(); + const router = useRouter(); + const prReviewEnabled = useFeatureFlag(FEATURE_FLAG_PR_REVIEW, true); const startRemediation = useStartSecurityRemediation(scope); const retryRemediation = useRetrySecurityRemediation(scope); const cancelRemediation = useCancelSecurityRemediation(scope); + const openPullRequest = (url: string) => { + const destination = resolveCodeReviewerOpenPrDestination(url, prReviewEnabled); + if (destination.kind === 'in-app') { + router.push(getPrReviewPath(destination.owner, destination.repo, destination.number)); + return; + } + void openExternalUrl(url, { label: 'pull request' }); + }; + if (isLoading && !analysis) { return ( @@ -80,6 +113,10 @@ export function FindingRemediationPanel({ } const { remediationCapability, remediationSummary, remediationAttempts } = analysis; + // A separately released client can talk to an old backend that omits the new + // `remediationTimeline` field, so the non-nullable type is not a runtime guarantee. + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition + const remediationTimeline = analysis.remediationTimeline ?? []; const latestAttempt = remediationAttempts[0] ?? null; const summaryPrUrl = remediationSummary?.prUrl; const presentation = getRemediationStatusPresentation(remediationSummary?.status ?? null, { @@ -186,7 +223,7 @@ export function FindingRemediationPanel({ ) : null} + {remediationTimeline.length > 0 ? ( + + Progress + + {remediationTimeline.map((event, index) => ( + + + {lookup(REMEDIATION_TIMELINE_LABELS, event.action) ?? event.action} + + + {timeAgo(parseTimestamp(event.occurredAt))} + + + ))} + + + ) : null} + {remediationAttempts.length > 0 ? ( { - void Linking.openURL(attemptUrl); + openPullRequest(attemptUrl); }} > diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.test.ts b/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.test.ts new file mode 100644 index 0000000000..dd1134a6dd --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.test.ts @@ -0,0 +1,397 @@ +/* eslint-disable max-lines -- the debounced delta sender and mutation payload suites share one mock harness */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as TanStackQuery from '@tanstack/react-query'; + +import { PERSONAL_SCOPE } from '@/lib/code-reviewer-config'; + +import { + REPO_SELECTION_DEBOUNCE_MS, + resetRepoSelectionSendersForTests, + useRepoSelectionToggle, +} from './use-code-reviewer-repo-selection'; + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown, vars?: unknown) => void; + onSettled?: () => void; + onSuccess?: (data: unknown, vars?: unknown) => void; +}; + +const personalPatchMutateMock = vi.fn(); +const orgPatchMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); +const mutateMock = vi.fn(); + +let lastCapturedOptions: MutationOptions | null = null; +let reviewConfigCache: { selectedRepositoryIds: (number | string)[] } | undefined = undefined; + +const setQueryDataMock = vi.fn((_key: unknown, updater: unknown) => { + if (typeof updater === 'function') { + reviewConfigCache = (updater as (old: typeof reviewConfigCache) => typeof reviewConfigCache)( + reviewConfigCache + ); + } +}); + +// The refetch-sync subscription callback captured by the mocked query cache, +// so a test can simulate a refetch response landing. +const subscriptionState = vi.hoisted(() => ({ + capturedSubscribe: null as ((event: unknown) => void) | null, +})); + +vi.mock('react', () => ({ + // Real useEffect needs a rendering context; run the effect synchronously so + // the refetch-sync subscription is set up when the hook is called as a plain + // function (same convention as chat-composer.test.ts). + useEffect: vi.fn((fn: () => void) => { + fn(); + }), +})); + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutate: mutateMock }; + }, + useQueryClient: () => ({ + getQueryData: () => reviewConfigCache, + setQueryData: setQueryDataMock, + invalidateQueries: invalidateQueriesMock, + getQueryCache: () => ({ + // eslint-disable-next-line promise/prefer-await-to-callbacks -- a cache subscription is callback-based by design + subscribe: (cb: (event: unknown) => void) => { + subscriptionState.capturedSubscribe = cb; + return () => { + subscriptionState.capturedSubscribe = null; + }; + }, + }), + }), + }; +}); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + personalReviewAgent: { + getReviewConfig: { queryKey: () => ['personalReviewAgent', 'getReviewConfig'] }, + }, + organizations: { + reviewAgent: { + getReviewConfig: { queryKey: () => ['organizations', 'reviewAgent', 'getReviewConfig'] }, + }, + }, + }), + trpcClient: { + personalReviewAgent: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + patchReviewConfig: { mutate: (vars: unknown) => personalPatchMutateMock(vars) }, + }, + organizations: { + reviewAgent: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + patchReviewConfig: { mutate: (vars: unknown) => orgPatchMutateMock(vars) }, + }, + }, + }, +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: (msg: string) => toastErrorMock(msg) }, +})); + +// use-code-reviewer.ts re-exports from use-reviewer-permission, which imports +// useRouter from expo-router. Loading the real module in node blows up on the +// expo-router source map, so stub the surface the re-export reaches. +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), +})); + +function getToggleRepo( + scope: string, + platform: 'github' | 'gitlab' | 'bitbucket' +): { toggleRepo: (id: number | string) => void; deltaOptions: MutationOptions } { + lastCapturedOptions = null; + // eslint-disable-next-line react-hooks/rules-of-hooks + const toggleRepo = useRepoSelectionToggle(scope, platform); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!lastCapturedOptions) { + throw new Error('mutation options for useSaveReviewConfigDelta were not captured'); + } + return { toggleRepo, deltaOptions: lastCapturedOptions }; +} + +function seedReviewConfigCache(ids: (number | string)[]) { + reviewConfigCache = { selectedRepositoryIds: ids }; +} + +// Simulates a refetch response landing: the response first overwrites the +// cache, then the query cache notifies subscribers (the refetch-sync hook). +function fireRefetch(scope: string, selectedRepositoryIds: (number | string)[]) { + reviewConfigCache = { selectedRepositoryIds }; + const queryKey = + scope === PERSONAL_SCOPE + ? ['personalReviewAgent', 'getReviewConfig'] + : ['organizations', 'reviewAgent', 'getReviewConfig']; + if (!subscriptionState.capturedSubscribe) { + throw new Error('refetch subscription was not captured'); + } + subscriptionState.capturedSubscribe({ + type: 'updated', + action: { type: 'success', data: { selectedRepositoryIds } }, + query: { queryHash: TanStackQuery.hashKey(queryKey) }, + }); +} + +beforeEach(() => { + lastCapturedOptions = null; + reviewConfigCache = undefined; + subscriptionState.capturedSubscribe = null; + personalPatchMutateMock.mockReset(); + orgPatchMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + mutateMock.mockReset(); + setQueryDataMock.mockClear(); + personalPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null }); + orgPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null }); +}); + +afterEach(() => { + resetRepoSelectionSendersForTests(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useRepoSelectionToggle debounced delta sender', () => { + it('collapses five rapid toggles into one mutation with the correct net delta', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + toggleRepo(3); + toggleRepo(4); + toggleRepo(5); + toggleRepo(6); + toggleRepo(7); + + expect(mutateMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + + expect(mutateMock).toHaveBeenCalledTimes(1); + expect(mutateMock.mock.calls[0]?.[0]).toEqual({ + add: [3, 4, 5, 6, 7], + remove: [], + optimisticSelection: [1, 2, 3, 4, 5, 6, 7], + }); + }); + + it('rolls back the optimistic cache to the server state when the save fails', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo, deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + + expect(mutateMock).toHaveBeenCalledTimes(1); + const vars = mutateMock.mock.calls[0]?.[0] as { optimisticSelection: unknown }; + deltaOptions.onError?.(new Error('Network unreachable'), vars); + + expect(reviewConfigCache?.selectedRepositoryIds).toEqual([1, 2]); + expect(toastErrorMock).toHaveBeenCalledWith('Network unreachable'); + }); + + it('sends nothing when the net delta is empty', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + toggleRepo(3); + toggleRepo(3); + + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + + expect(mutateMock).not.toHaveBeenCalled(); + }); + + it('clears the pending intent after a net-zero toggle so a later refetch does not resurrect it', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + // Toggle a repo on then off within the debounce window: the net delta is + // empty, so the pending intent must clear to the server state. + toggleRepo(3); + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + expect(mutateMock).not.toHaveBeenCalled(); + + // A later refetch lands with a different server selection (e.g. a web + // edit). The cleared intent must not re-apply the stale selection. + fireRefetch(PERSONAL_SCOPE, [1, 4]); + + expect(reviewConfigCache?.selectedRepositoryIds).toEqual([1, 4]); + + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + expect(mutateMock).not.toHaveBeenCalled(); + }); + + it('diffs the next toggle against the last confirmed selection after a successful save', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo, deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + const firstVars = mutateMock.mock.calls[0]?.[0]; + deltaOptions.onSuccess?.({ success: true, webhookSync: null }, firstVars); + + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + + expect(mutateMock).toHaveBeenCalledTimes(2); + expect(mutateMock.mock.calls[1]?.[0]).toEqual({ + add: [], + remove: [3], + optimisticSelection: [1, 2], + }); + }); + + it('re-sends a toggle made during the settle-to-refetch window so it stays visible', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1, 2]); + const { toggleRepo, deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + expect(mutateMock).toHaveBeenCalledTimes(1); + const firstVars = mutateMock.mock.calls[0]?.[0]; + + // A second toggle lands while the first save is still in flight. + toggleRepo(4); + + // The first save settles, then the invalidation refetch lands with only + // the first delta applied. + deltaOptions.onSuccess?.({ success: true, webhookSync: null }, firstVars); + fireRefetch(PERSONAL_SCOPE, [1, 2, 3]); + + // The refetch must not erase the pending toggle from the cache. + expect(reviewConfigCache?.selectedRepositoryIds).toEqual([1, 2, 3, 4]); + + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + expect(mutateMock).toHaveBeenCalledTimes(2); + expect(mutateMock.mock.calls[1]?.[0]).toEqual({ + add: [4], + remove: [], + optimisticSelection: [1, 2, 3, 4], + }); + }); + + it('resyncs the baseline with a refetched server selection so an external edit toggles correctly', () => { + vi.useFakeTimers(); + seedReviewConfigCache([1]); + const { toggleRepo, deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + // First toggle establishes a stale baseline of [1, 3]. + toggleRepo(3); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + const firstVars = mutateMock.mock.calls[0]?.[0]; + deltaOptions.onSuccess?.({ success: true, webhookSync: null }, firstVars); + + // External edit: the web app removes 3 and adds 2. A refetch lands. + fireRefetch(PERSONAL_SCOPE, [1, 2]); + + // Toggling the externally-added repo must send remove:[2], not diff + // against the stale [1, 3] baseline. + toggleRepo(2); + vi.advanceTimersByTime(REPO_SELECTION_DEBOUNCE_MS); + + expect(mutateMock).toHaveBeenCalledTimes(2); + expect(mutateMock.mock.calls[1]?.[0]).toEqual({ + add: [], + remove: [2], + optimisticSelection: [1], + }); + }); +}); + +describe('useSaveReviewConfigDelta mutationFn payload shape', () => { + it('sends a numeric delta for a personal github patch and drops string ids', async () => { + const { deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'github'); + + await deltaOptions.mutationFn?.({ + add: [3, 4, 'bitbucket-uuid'], + remove: [1, 'bitbucket-uuid-2'], + optimisticSelection: [2, 3, 4], + }); + + expect(personalPatchMutateMock).toHaveBeenCalledTimes(1); + expect(personalPatchMutateMock.mock.calls[0]?.[0]).toEqual({ + platform: 'github', + selectedRepositoryDelta: { add: [3, 4], remove: [1] }, + }); + expect(orgPatchMutateMock).not.toHaveBeenCalled(); + }); + + it('sends a mixed delta and autoConfigureWebhooks for an org gitlab patch', async () => { + const { deltaOptions } = getToggleRepo('org_42', 'gitlab'); + + await deltaOptions.mutationFn?.({ + add: [3, 'bitbucket-uuid'], + remove: [1], + optimisticSelection: [3, 'bitbucket-uuid'], + }); + + expect(orgPatchMutateMock).toHaveBeenCalledTimes(1); + expect(orgPatchMutateMock.mock.calls[0]?.[0]).toEqual({ + organizationId: 'org_42', + platform: 'gitlab', + selectedRepositoryDelta: { add: [3, 'bitbucket-uuid'], remove: [1] }, + autoConfigureWebhooks: true, + }); + expect(personalPatchMutateMock).not.toHaveBeenCalled(); + }); + + it('sends a numeric delta and autoConfigureWebhooks for a personal gitlab patch', async () => { + const { deltaOptions } = getToggleRepo(PERSONAL_SCOPE, 'gitlab'); + + await deltaOptions.mutationFn?.({ + add: [3], + remove: [1], + optimisticSelection: [3], + }); + + expect(personalPatchMutateMock).toHaveBeenCalledTimes(1); + expect(personalPatchMutateMock.mock.calls[0]?.[0]).toEqual({ + platform: 'gitlab', + selectedRepositoryDelta: { add: [3], remove: [1] }, + autoConfigureWebhooks: true, + }); + expect(orgPatchMutateMock).not.toHaveBeenCalled(); + }); + + it('writes the GitLab webhook warning flag when a delta save reports sync errors', async () => { + orgPatchMutateMock.mockResolvedValue({ + success: true, + webhookSync: { errors: ['repo 3 webhook sync failed'] }, + }); + const { deltaOptions } = getToggleRepo('org_42', 'gitlab'); + + await deltaOptions.mutationFn?.({ + add: [3], + remove: [], + optimisticSelection: [3], + }); + + expect(setQueryDataMock).toHaveBeenCalledWith( + ['codeReviewerGitLabWebhookWarning', 'org_42', 'gitlab'], + true + ); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.ts b/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.ts new file mode 100644 index 0000000000..08d5c614b3 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-code-reviewer-repo-selection.ts @@ -0,0 +1,256 @@ +import { useEffect } from 'react'; +import { hashKey, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { announcingToast } from '@/lib/a11y/announcing-toast'; +import { type ReviewConfigData, type ReviewerPlatform } from '@/lib/code-reviewer-config'; +import { chainSave } from '@/lib/hooks/save-chain'; +import { trpcClient } from '@/lib/trpc'; + +import { + gitLabWebhookWarningQueryKey, + isPersonal, + toNumericRepositoryIds, + toPersonalPlatform, + useReviewConfigCacheReader, + useReviewConfigQueryKey, +} from './use-code-reviewer'; + +export const REPO_SELECTION_DEBOUNCE_MS = 500; + +type RepoSelectionDelta = { + add: (number | string)[]; + remove: (number | string)[]; +}; + +type RepoSelectionSaveVars = RepoSelectionDelta & { + optimisticSelection: (number | string)[]; +}; + +type RepoSelectionSender = { + timer: ReturnType | null; + // The latest user-intended selection. Null means no toggle is pending. + pendingSelection: (number | string)[] | null; + // The last server-confirmed selection. Null means the server state is not + // yet known (no toggle and no refetch have synced it). + serverSelection: (number | string)[] | null; + // The mutation trigger of the hook instance that currently owns this key. + mutate: ((vars: RepoSelectionSaveVars) => void) | null; +}; + +// One pending debounced send per scope+platform. The timer closes over the +// sender state, so a remount never retargets an older timer. `serverSelection` +// is the last server-confirmed selection; `pendingSelection` is the latest +// user-intended selection and is null while nothing is pending. +const repoSelectionSenders = new Map(); + +function getRepoSelectionSender(key: string): RepoSelectionSender { + let sender = repoSelectionSenders.get(key); + if (!sender) { + sender = { timer: null, pendingSelection: null, serverSelection: null, mutate: null }; + repoSelectionSenders.set(key, sender); + } + return sender; +} + +function sameSelection(a: (number | string)[] | null, b: (number | string)[] | null): boolean { + if (a === null || b === null) { + return a === b; + } + if (a.length !== b.length) { + return false; + } + return a.every(id => b.includes(id)); +} + +// Schedules the trailing-edge 500ms send. The delta is computed at fire time +// from the module-level pending/server selections, so no intermediate rapid +// toggle is lost and a refetch that clobbers the optimistic cache cannot +// collapse the send to an empty delta. +function scheduleSend(sender: RepoSelectionSender): void { + if (sender.timer) { + clearTimeout(sender.timer); + } + sender.timer = setTimeout(() => { + sender.timer = null; + const pending = sender.pendingSelection; + const server = sender.serverSelection ?? []; + if (pending === null) { + return; + } + const add = pending.filter(id => !server.includes(id)); + const remove = server.filter(id => !pending.includes(id)); + if (add.length === 0 && remove.length === 0) { + // The intent now equals the server state, so nothing is left to send. + // Clear it so a later refetch does not mistake it for live user intent. + sender.pendingSelection = null; + return; + } + sender.mutate?.({ add, remove, optimisticSelection: pending }); + }, REPO_SELECTION_DEBOUNCE_MS); +} + +/** + * Sends a `selectedRepositoryDelta` patch without touching the optimistic + * cache: the delta is a diff, not a config field, so it must never be merged + * into the cached `ReviewConfigData` the way a full-array `ConfigPatch` is. + */ +function useSaveReviewConfigDelta(scope: string, platform: ReviewerPlatform) { + const queryClient = useQueryClient(); + const queryKey = useReviewConfigQueryKey(scope, platform); + const webhookWarningQueryKey = gitLabWebhookWarningQueryKey(scope, platform); + const saveChainKey = `${scope}:${platform}`; + + return useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (vars: RepoSelectionSaveVars) => + chainSave(saveChainKey, async () => { + // The personal schema only accepts numeric repository IDs (bitbucket, + // the only string-ID platform, is org-only). Same narrowing as + // useSaveReviewConfig's full-array path. + const gitlabAutoConfigure = + platform === 'gitlab' ? ({ autoConfigureWebhooks: true } as const) : ({} as const); + const result = isPersonal(scope) + ? await trpcClient.personalReviewAgent.patchReviewConfig.mutate({ + platform: toPersonalPlatform(platform), + selectedRepositoryDelta: { + add: toNumericRepositoryIds(vars.add), + remove: toNumericRepositoryIds(vars.remove), + }, + ...gitlabAutoConfigure, + }) + : await trpcClient.organizations.reviewAgent.patchReviewConfig.mutate({ + organizationId: scope, + platform, + selectedRepositoryDelta: { add: vars.add, remove: vars.remove }, + ...gitlabAutoConfigure, + }); + if (!result.success) { + throw new Error('Failed to save review config'); + } + if (platform === 'gitlab') { + queryClient.setQueryData( + webhookWarningQueryKey, + (result.webhookSync?.errors.length ?? 0) > 0 + ); + } + return result; + }), + onError: (error, vars) => { + const sender = getRepoSelectionSender(saveChainKey); + // Roll back only when no newer toggle superseded this failed save. A + // newer toggle's own debounced send reconciles against the unchanged + // server state, so clobbering it here would lose that selection. + if ( + sender.pendingSelection !== null && + sameSelection(sender.pendingSelection, vars.optimisticSelection) + ) { + const serverSelection = sender.serverSelection; + if (serverSelection !== null) { + queryClient.setQueryData(queryKey, old => + old ? { ...old, selectedRepositoryIds: serverSelection } : old + ); + } + sender.pendingSelection = null; + } + announcingToast.error(error.message); + }, + onSuccess: (_result, vars) => { + const sender = getRepoSelectionSender(saveChainKey); + sender.serverSelection = vars.optimisticSelection; + // Clear the pending intent only when it matches what this save just + // confirmed; a newer toggle keeps its own pending send alive. + if ( + sender.pendingSelection !== null && + sameSelection(sender.pendingSelection, vars.optimisticSelection) + ) { + sender.pendingSelection = null; + } + }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + onSettled: () => queryClient.invalidateQueries({ queryKey }), + }); +} + +/** + * Returns a `toggleRepo` that applies the optimistic cache update immediately + * and schedules a trailing-edge 500ms debounced delta send keyed on + * scope+platform. The delta is computed at send time against the last + * server-confirmed selection, so no intermediate rapid toggle is lost. + */ +export function useRepoSelectionToggle(scope: string, platform: ReviewerPlatform) { + const queryClient = useQueryClient(); + const queryKey = useReviewConfigQueryKey(scope, platform); + const senderKey = `${scope}:${platform}`; + const readConfig = useReviewConfigCacheReader(scope, platform); + const deltaSave = useSaveReviewConfigDelta(scope, platform); + + const sender = getRepoSelectionSender(senderKey); + sender.mutate = deltaSave.mutate; + + const queryKeyHash = hashKey(queryKey); + + useEffect(() => { + const queryCache = queryClient.getQueryCache(); + return queryCache.subscribe(event => { + if (event.type !== 'updated' || event.action.type !== 'success') { + return; + } + // Our own optimistic setQueryData also dispatches a success action with + // `manual: true`; only a real fetch (refetch) resyncs the baseline. + if (event.action.manual) { + return; + } + if (event.query.queryHash !== queryKeyHash) { + return; + } + const fetched = (event.action.data as ReviewConfigData | undefined)?.selectedRepositoryIds; + if (fetched === undefined) { + return; + } + // Resync the baseline with the refetched server selection. If a user + // toggle is still pending and differs from the fetched state, re-apply + // it to the cache and re-schedule the send so the toggle reaches the + // server and stays visible. + const refetchedSender = getRepoSelectionSender(senderKey); + refetchedSender.serverSelection = fetched; + const pending = refetchedSender.pendingSelection; + if (pending !== null && !sameSelection(pending, fetched)) { + queryClient.setQueryData(queryKey, old => + old ? { ...old, selectedRepositoryIds: pending } : old + ); + scheduleSend(refetchedSender); + } + }); + // eslint-disable-next-line react/exhaustive-deps -- queryKey is derived from queryKeyHash; re-subscribing on every render would churn the cache listener + }, [queryClient, queryKeyHash, senderKey]); + + return (id: number | string) => { + const current = readConfig()?.selectedRepositoryIds ?? []; + const next = current.includes(id) + ? current.filter(existing => existing !== id) + : [...current, id]; + + const currentSender = getRepoSelectionSender(senderKey); + // First toggle: the pre-optimistic cache is the server-confirmed value. + currentSender.serverSelection ??= current; + currentSender.pendingSelection = next; + + queryClient.setQueryData(queryKey, old => + old ? { ...old, selectedRepositoryIds: next } : old + ); + + scheduleSend(currentSender); + }; +} + +// Test-only: cancels every pending debounced timer and clears the sender +// state so a test never leaks a fire into a later case (same pattern as +// resetDraftTimersForTests in drafts.ts). +export function resetRepoSelectionSendersForTests(): void { + for (const sender of repoSelectionSenders.values()) { + if (sender.timer) { + clearTimeout(sender.timer); + } + } + repoSelectionSenders.clear(); +} diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 141f7c561b..aa2ebc99f3 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -13,7 +13,7 @@ import { pick } from '@/lib/utils'; export { PERSONAL_SCOPE }; -function isPersonal(scope: string) { +export function isPersonal(scope: string) { return scope === PERSONAL_SCOPE; } @@ -27,10 +27,24 @@ function isPersonal(scope: string) { // construction). This narrows a ReviewerPlatform down to what the personal // procedures accept, without an `as` cast — the 'bitbucket' branch is dead // whenever scope is actually personal. -function toPersonalPlatform(platform: ReviewerPlatform): 'github' | 'gitlab' { +export function toPersonalPlatform(platform: ReviewerPlatform): 'github' | 'gitlab' { return platform === 'bitbucket' ? 'github' : platform; } +/** + * Narrows a mixed `number | string` id array down to numeric ids. The + * personal schema only accepts numeric repository IDs (bitbucket, the only + * string-ID platform, is org-only), so the personal PATCH path must drop + * string ids before sending. Shared by the full-array save and the delta + * save so the two copies of this contract rule cannot drift. + */ +export function toNumericRepositoryIds(ids: (number | string)[]): number[] { + return ids.filter( + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- distinguishing a number id from a string id in a mixed primitive union has no non-typeof narrowing + (id): id is number => typeof id === 'number' + ); +} + // Personal and org procedures resolve to nominally distinct tRPC option // types even when structurally identical, so we can't pick between them // with a ternary and spread the result — TypeScript treats the branches as @@ -119,7 +133,7 @@ export function useReviewConfig( return (isPersonal(scope) ? personal : org) as UseQueryResult; } -function useReviewConfigQueryKey(scope: string, platform: ReviewerPlatform) { +export function useReviewConfigQueryKey(scope: string, platform: ReviewerPlatform) { const trpc = useTRPC(); return isPersonal(scope) ? trpc.personalReviewAgent.getReviewConfig.queryKey({ platform: toPersonalPlatform(platform) }) @@ -182,7 +196,7 @@ export function useToggleReviewer(scope: string, platform: ReviewerPlatform) { }); } -function gitLabWebhookWarningQueryKey(scope: string, platform: ReviewerPlatform) { +export function gitLabWebhookWarningQueryKey(scope: string, platform: ReviewerPlatform) { return ['codeReviewerGitLabWebhookWarning', scope, platform] as const; } @@ -251,10 +265,7 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { // still be a real edit and could clobber stored values. const narrowedSelectedRepositoryIds = rawSelectedRepositoryIds !== undefined - ? rawSelectedRepositoryIds.filter( - // oxlint-disable-next-line anti-slop/no-runtime-typeof -- distinguishing a number id from a string id in a mixed primitive union has no non-typeof narrowing - (id): id is number => typeof id === 'number' - ) + ? toNumericRepositoryIds(rawSelectedRepositoryIds) : undefined; const narrowedRepositoryModelOverrides = rawRepositoryModelOverrides !== undefined @@ -376,3 +387,31 @@ export function useConnectBitbucket(scope: string) { onSuccess: () => queryClient.invalidateQueries({ queryKey }), }); } + +// Review memory only exists for GitHub, so the owner input pins the platform +// and only varies the scope segment (personal vs. an organization id). +function reviewMemoryOwnerInput(scope: string) { + return isPersonal(scope) + ? { platform: 'github' as const } + : { organizationId: scope, platform: 'github' as const }; +} + +export function useSetReviewMemoryEnabled(scope: string) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const ownerInput = reviewMemoryOwnerInput(scope); + + return useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (enabled: boolean) => + trpcClient.reviewMemory.setEnabled.mutate({ ...ownerInput, enabled }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.reviewMemory.getDashboardSummary.queryKey(ownerInput), + }); + }, + onError: error => { + announcingToast.error(error.message); + }, + }); +} diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts index 2b6b9afa16..4e1d8c1b77 100644 --- a/apps/mobile/src/lib/hooks/use-organization-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts @@ -12,6 +12,24 @@ const onMutationError = (error: { message: string }) => { announcingToast.error(error.message || 'Something went wrong'); }; +// Distributive helpers: `OrgWithMembers` is a union of the admin and member +// variants, so a plain `{ ...old, members: ... }` spread loses the variant +// correlation. These preserve it by mapping over `T['members']` for the +// concrete `T`. +function mapMembers( + old: T, + fn: (member: T['members'][number]) => T['members'][number] +): T { + return { ...old, members: old.members.map(fn) }; +} + +function filterMembers( + old: T, + fn: (member: T['members'][number]) => boolean +): T { + return { ...old, members: old.members.filter(fn) }; +} + type UseOrganizationMutationsOptions = { /** * member-limit-sheet renders `updateMember` errors inline (Pattern P2) and @@ -137,9 +155,8 @@ export function useOrganizationMutations( dailyUsageLimitUsd?: number | null; }) => trpcClient.organizations.members.update.mutate({ organizationId, ...input }), ...optimistic<{ memberId: string; role?: OrgRole; dailyUsageLimitUsd?: number | null }>( - (old, input) => ({ - ...old, - members: old.members.map(member => + (old, input) => + mapMembers(old, member => member.status === 'active' && member.id === input.memberId ? { ...member, @@ -150,7 +167,6 @@ export function useOrganizationMutations( } : member ), - }), { silent: silenceUpdateMemberToast } ), }), @@ -159,24 +175,21 @@ export function useOrganizationMutations( // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (input: { memberId: string }) => trpcClient.organizations.members.remove.mutate({ organizationId, ...input }), - ...optimistic<{ memberId: string }>((old, input) => ({ - ...old, - members: old.members.filter( - member => !(member.status === 'active' && member.id === input.memberId) - ), - })), + ...optimistic<{ memberId: string }>((old, input) => + filterMembers(old, member => !(member.status === 'active' && member.id === input.memberId)) + ), }), deleteInvite: useMutation({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (input: { inviteId: string }) => trpcClient.organizations.members.deleteInvite.mutate({ organizationId, ...input }), - ...optimistic<{ inviteId: string }>((old, input) => ({ - ...old, - members: old.members.filter( + ...optimistic<{ inviteId: string }>((old, input) => + filterMembers( + old, member => !(member.status === 'invited' && member.inviteId === input.inviteId) - ), - })), + ) + ), }), // No onMutationError toast here: low-balance-alert-sheet (the only diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 2df36c1335..5a8acb5ea9 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -100,10 +100,18 @@ export function useOrgWithMembers(organizationId: string | null) { } export type OrgWithMembers = NonNullable['data']>; -type OrgMember = OrgWithMembers['members'][number]; +export type OrgMember = OrgWithMembers['members'][number]; export type ActiveOrgMember = Extract; export type InvitedOrgMember = Extract; +export function isActiveOrgMember(member: OrgMember): member is ActiveOrgMember { + return member.status === 'active'; +} + +export function isInvitedOrgMember(member: OrgMember): member is InvitedOrgMember { + return member.status === 'invited'; +} + /** * Parent organization's Kilo Pass for Orgs summary. The API is restricted to * the parent agreement owner (`organizationParentBillingProcedure` rejects diff --git a/apps/mobile/src/lib/hooks/use-security-agent-commands.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-security-agent-commands.mounted.test.tsx new file mode 100644 index 0000000000..2c7f54f21e --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-agent-commands.mounted.test.tsx @@ -0,0 +1,259 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// P1-G-51b mounted wiring tests for `useSecurityAgentCommands`: both batch +// query shapes stay mounted unconditionally with `enabled` gating, the batch +// carries the first 100 ids with the overflow going to per-command queries, +// the batch keeps React Query's reconnect/mount refetch defaults, and the +// old-server fallback engages only on the procedure-missing signature. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + _resetBatchProcedureAvailabilityForTests, + useSecurityAgentCommands, +} from './use-security-agent-commands'; + +type QueryOptions = { + queryKey?: unknown; + enabled?: boolean; + refetchInterval?: (query: { state: { data?: unknown } }) => unknown; + refetchOnReconnect?: unknown; + refetchOnMount?: unknown; +}; + +const useQueryMock = vi.hoisted(() => vi.fn()); +const useQueriesMock = vi.hoisted(() => vi.fn()); +const queryClientMock = vi.hoisted(() => ({ + getQueryData: vi.fn(() => []), + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: useQueryMock, + useQueries: useQueriesMock, + useQueryClient: () => queryClientMock, +})); + +const trpcStub = { + securityAgent: { + listActiveCommands: { + queryOptions: () => ({ queryKey: ['securityAgent', 'listActiveCommands'] }), + }, + getCommandStatus: { + queryOptions: (input: { commandId: string }) => ({ + queryKey: ['securityAgent', 'getCommandStatus', input], + }), + }, + getCommandStatuses: { + queryOptions: (input: { commandIds: string[] }) => ({ + queryKey: ['securityAgent', 'getCommandStatuses', input], + }), + }, + }, + organizations: { + securityAgent: { + listActiveCommands: { + queryOptions: (input: { organizationId: string }) => ({ + queryKey: ['organizations', 'securityAgent', 'listActiveCommands', input], + }), + }, + getCommandStatus: { + queryOptions: (input: { organizationId: string; commandId: string }) => ({ + queryKey: ['organizations', 'securityAgent', 'getCommandStatus', input], + }), + }, + getCommandStatuses: { + queryOptions: (input: { organizationId: string; commandIds: string[] }) => ({ + queryKey: ['organizations', 'securityAgent', 'getCommandStatuses', input], + }), + }, + }, + }, +}; + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => trpcStub, +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + InteractionManager: { runAfterInteractions: vi.fn() }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +let trackedIdsFixture: string[] = []; +let batchErrorFixture: unknown = null; +const useQueryOptions: QueryOptions[] = []; +const useQueriesOptions: { queries: QueryOptions[] }[] = []; + +function makeIds(count: number): string[] { + return Array.from({ length: count }, (_, i) => `id-${i}`); +} + +function isTrackedIdsKey(key: unknown): boolean { + return Array.isArray(key) && key[0] === 'security-agent-command-ids'; +} + +function isBatchKey(key: unknown, scope: 'personal' | 'org'): boolean { + if (!Array.isArray(key)) { + return false; + } + if (scope === 'personal') { + return key[0] === 'securityAgent' && key[1] === 'getCommandStatuses'; + } + return ( + key[0] === 'organizations' && key[1] === 'securityAgent' && key[2] === 'getCommandStatuses' + ); +} + +function batchQueryOptions(scope: 'personal' | 'org'): QueryOptions | undefined { + const matches = useQueryOptions.filter(opts => isBatchKey(opts.queryKey, scope)); + return matches.at(-1); +} + +function Probe({ scope }: Readonly<{ scope: string }>) { + useSecurityAgentCommands(scope); + return null; +} + +function mount(scope: string): void { + act(() => { + TestRenderer.create(createElement(Probe, { scope })); + }); +} + +beforeEach(() => { + _resetBatchProcedureAvailabilityForTests(); + trackedIdsFixture = []; + batchErrorFixture = null; + useQueryOptions.length = 0; + useQueriesOptions.length = 0; + queryClientMock.getQueryData.mockReturnValue([]); + queryClientMock.setQueryData.mockClear(); + queryClientMock.invalidateQueries.mockClear(); + + useQueryMock.mockImplementation((options: QueryOptions) => { + useQueryOptions.push(options); + const key = options.queryKey; + if (isTrackedIdsKey(key)) { + return { + data: trackedIdsFixture, + error: null, + isError: false, + state: { data: trackedIdsFixture }, + }; + } + if (isBatchKey(key, 'personal') || isBatchKey(key, 'org')) { + return { + data: undefined, + error: batchErrorFixture, + isError: batchErrorFixture !== null, + state: { data: undefined }, + }; + } + return { data: undefined, error: null, isError: false, state: { data: undefined } }; + }); + + useQueriesMock.mockImplementation((options: { queries: QueryOptions[] }) => { + useQueriesOptions.push(options); + return options.queries.map(() => ({ + data: undefined, + error: null, + isError: false, + state: { data: undefined }, + })); + }); +}); + +describe('useSecurityAgentCommands (batch observer wiring)', () => { + it('mounts both batch query shapes with enabled gating (no conditional hook call)', () => { + trackedIdsFixture = makeIds(3); + mount('personal'); + + expect(batchQueryOptions('personal')?.enabled).toBe(true); + expect(batchQueryOptions('org')?.enabled).toBe(false); + }); + + it('enables the organization batch shape for an org scope', () => { + trackedIdsFixture = makeIds(2); + mount(ORG_ID); + + expect(batchQueryOptions('personal')?.enabled).toBe(false); + expect(batchQueryOptions('org')?.enabled).toBe(true); + }); + + it('keeps React Query reconnect/mount refetch defaults on the batch query', () => { + trackedIdsFixture = makeIds(2); + mount('personal'); + + const batch = batchQueryOptions('personal'); + expect(batch?.refetchOnReconnect).not.toBe(false); + expect(batch?.refetchOnMount).not.toBe(false); + }); + + it('polls the batch only while a returned command is active', () => { + trackedIdsFixture = makeIds(1); + mount('personal'); + + const refetchInterval = batchQueryOptions('personal')?.refetchInterval; + expect(refetchInterval?.({ state: { data: [] } })).toBe(false); + expect(refetchInterval?.({ state: { data: undefined } })).toBe(false); + expect(refetchInterval?.({ state: { data: [{ status: 'accepted' }] } })).toBe(3000); + }); + + it('sends 100 ids to the batch and the overflow to per-command queries', () => { + trackedIdsFixture = makeIds(150); + mount('personal'); + + const batch = batchQueryOptions('personal'); + const batchKey = batch?.queryKey as [string, string, { commandIds: string[] }]; + expect(batchKey[2].commandIds).toHaveLength(100); + + const lastQueries = useQueriesOptions.at(-1); + expect(lastQueries?.queries).toHaveLength(50); + }); + + it('disables the batch and runs no per-command queries with no tracked ids', () => { + trackedIdsFixture = []; + mount('personal'); + + expect(batchQueryOptions('personal')?.enabled).toBe(false); + const lastQueries = useQueriesOptions.at(-1); + expect(lastQueries?.queries).toHaveLength(0); + }); + + it('engages the per-command fallback only on the procedure-missing signature', () => { + trackedIdsFixture = makeIds(3); + batchErrorFixture = { + message: 'No "query"-procedure on path "securityAgent.getCommandStatuses"', + data: { code: 'NOT_FOUND' }, + }; + mount('personal'); + + // The fallback effect flushes inside the mount act: the batch disables and + // the per-command queries cover every tracked id. + expect(batchQueryOptions('personal')?.enabled).toBe(false); + const lastQueries = useQueriesOptions.at(-1); + expect(lastQueries?.queries).toHaveLength(3); + }); + + it('does not engage the fallback on a bare NOT_FOUND', () => { + trackedIdsFixture = makeIds(3); + batchErrorFixture = { + message: 'Security Agent command not found', + data: { code: 'NOT_FOUND' }, + }; + mount('personal'); + + expect(batchQueryOptions('personal')?.enabled).toBe(true); + const lastQueries = useQueriesOptions.at(-1); + expect(lastQueries?.queries).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-commands.test.ts b/apps/mobile/src/lib/hooks/use-security-agent-commands.test.ts new file mode 100644 index 0000000000..134a06896e --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-agent-commands.test.ts @@ -0,0 +1,305 @@ +// P1-G-51b unit tests for the bounded batch observer's pure helpers: the +// 100-id slice, the procedure-missing fallback signature, the active-only +// poll interval, the terminal reconciliation (omission-equals-NOT_FOUND), and +// the push invalidation target. The hook wiring (enabled gating, no +// conditional hook call, reconnect defaults) is asserted in the mounted test. +import { describe, expect, it, vi } from 'vitest'; + +import { + activeCommandPollInterval, + invalidateSecurityAgentCommandObserver, +} from './use-security-agent-commands'; +import { + BATCH_COMMAND_LIMIT, + isMissingBatchProcedureError, + reconcileCommandStatuses, + type SecurityCommand, + splitTrackedCommandIds, +} from '@/lib/security-agent'; + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({}), +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + InteractionManager: { runAfterInteractions: vi.fn() }, +})); + +function makeCommand(overrides: Partial = {}): SecurityCommand { + return { + id: 'cmd-1', + commandType: 'sync', + origin: 'manual', + findingId: null, + repoFullName: null, + status: 'accepted', + resultCode: null, + resultMetadata: null, + lastErrorRedacted: null, + acceptedAt: null, + startedAt: null, + completedAt: null, + updatedAt: null, + ...overrides, + }; +} + +function makeIds(count: number): string[] { + return Array.from({ length: count }, (_, i) => `id-${i}`); +} + +function trpcError(code: string, message: string): unknown { + return { message, data: { code } }; +} + +type InvalidationTrpcStub = { + securityAgent: { + getCommandStatuses: { queryKey: () => string[] }; + listActiveCommands: { queryKey: () => string[] }; + }; + organizations: { + securityAgent: { + getCommandStatuses: { queryKey: () => string[] }; + listActiveCommands: { queryKey: () => string[] }; + }; + }; +}; + +function makeTrpcStub(): InvalidationTrpcStub { + return { + securityAgent: { + getCommandStatuses: { queryKey: () => ['securityAgent', 'getCommandStatuses'] }, + listActiveCommands: { queryKey: () => ['securityAgent', 'listActiveCommands'] }, + }, + organizations: { + securityAgent: { + getCommandStatuses: { + queryKey: () => ['organizations', 'securityAgent', 'getCommandStatuses'], + }, + listActiveCommands: { + queryKey: () => ['organizations', 'securityAgent', 'listActiveCommands'], + }, + }, + }, + }; +} + +describe('splitTrackedCommandIds (100-id slice)', () => { + it('slices the first 100 ids into the batch and the rest into overflow', () => { + const { batchIds, overflowIds } = splitTrackedCommandIds(makeIds(150)); + + expect(batchIds).toHaveLength(BATCH_COMMAND_LIMIT); + expect(overflowIds).toHaveLength(50); + expect(batchIds.at(0)).toBe('id-0'); + expect(batchIds.at(99)).toBe('id-99'); + expect(overflowIds.at(0)).toBe('id-100'); + expect(overflowIds.at(49)).toBe('id-149'); + }); + + it('returns an empty overflow slice for 100 or fewer ids', () => { + expect(splitTrackedCommandIds([])).toEqual({ batchIds: [], overflowIds: [] }); + + const { batchIds, overflowIds } = splitTrackedCommandIds(['a', 'b']); + expect(batchIds).toEqual(['a', 'b']); + expect(overflowIds).toEqual([]); + }); +}); + +describe('isMissingBatchProcedureError (fallback signature)', () => { + it('engages only on the procedure-missing NOT_FOUND signature', () => { + expect( + isMissingBatchProcedureError( + trpcError('NOT_FOUND', 'No "query"-procedure on path "securityAgent.getCommandStatuses"') + ) + ).toBe(true); + }); + + it('rejects a bare NOT_FOUND (the per-command purge path)', () => { + expect( + isMissingBatchProcedureError(trpcError('NOT_FOUND', 'Security Agent command not found')) + ).toBe(false); + }); + + it('rejects a non-NOT_FOUND code even with the procedure-missing message', () => { + expect( + isMissingBatchProcedureError( + trpcError('INTERNAL_SERVER_ERROR', 'No "query"-procedure on path "x"') + ) + ).toBe(false); + }); + + it('rejects non-tRPC errors and empty values', () => { + expect(isMissingBatchProcedureError(new Error('Network request failed'))).toBe(false); + expect(isMissingBatchProcedureError(null)).toBe(false); + expect(isMissingBatchProcedureError(undefined)).toBe(false); + }); +}); + +describe('activeCommandPollInterval (no polling with no active commands)', () => { + it('returns false for an empty or absent result', () => { + expect(activeCommandPollInterval(undefined)).toBe(false); + expect(activeCommandPollInterval([])).toBe(false); + }); + + it('returns false when every returned command is terminal', () => { + expect(activeCommandPollInterval([makeCommand({ status: 'succeeded' })])).toBe(false); + expect(activeCommandPollInterval([makeCommand({ status: 'failed' })])).toBe(false); + }); + + it('returns the 3s interval while any returned command is active', () => { + expect(activeCommandPollInterval([makeCommand({ status: 'accepted' })])).toBe(3000); + expect( + activeCommandPollInterval([ + makeCommand({ status: 'succeeded' }), + makeCommand({ id: 'cmd-2', status: 'running' }), + ]) + ).toBe(3000); + }); +}); + +describe('reconcileCommandStatuses (terminal + omission purge)', () => { + const processed = new Set(); + + it('collects a terminal command once', () => { + const terminal = makeCommand({ id: 'cmd-1', status: 'succeeded' }); + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-1'], + batchIds: ['cmd-1'], + perCommandIds: [], + batchCommands: [terminal], + batchSettled: true, + perCommandResults: [], + processedTerminalIds: processed, + }); + + expect(result.terminalCommands).toEqual([terminal]); + expect(result.unavailableIds).toEqual([]); + }); + + it('skips a terminal command whose id is already processed (no second toast)', () => { + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-1'], + batchIds: ['cmd-1'], + perCommandIds: [], + batchCommands: [makeCommand({ id: 'cmd-1', status: 'succeeded' })], + batchSettled: true, + perCommandResults: [], + processedTerminalIds: new Set(['cmd-1']), + }); + + expect(result.terminalCommands).toEqual([]); + expect(result.unavailableIds).toEqual([]); + }); + + it('does not drop an already-processed id the settled batch omitted', () => { + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-1'], + batchIds: ['cmd-1'], + perCommandIds: [], + batchCommands: [], + batchSettled: true, + perCommandResults: [], + processedTerminalIds: new Set(['cmd-1']), + }); + + expect(result.unavailableIds).toEqual([]); + expect(result.terminalCommands).toEqual([]); + }); + + it('purges a batch id the settled batch omitted (omission-equals-NOT_FOUND)', () => { + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-1', 'cmd-2'], + batchIds: ['cmd-1', 'cmd-2'], + perCommandIds: [], + batchCommands: [makeCommand({ id: 'cmd-1', status: 'accepted' })], + batchSettled: true, + perCommandResults: [], + processedTerminalIds: processed, + }); + + expect(result.unavailableIds).toEqual(['cmd-2']); + expect(result.terminalCommands).toEqual([]); + }); + + it('does not purge a batch id while the batch is still loading', () => { + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-1'], + batchIds: ['cmd-1'], + perCommandIds: [], + batchCommands: undefined, + batchSettled: false, + perCommandResults: [], + processedTerminalIds: processed, + }); + + expect(result.unavailableIds).toEqual([]); + }); + + it('purges an overflow id only when its per-command query is NOT_FOUND', () => { + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-100', 'cmd-101'], + batchIds: ['cmd-100'], + perCommandIds: ['cmd-101'], + batchCommands: [makeCommand({ id: 'cmd-100', status: 'accepted' })], + batchSettled: true, + perCommandResults: [{ error: { data: { code: 'NOT_FOUND' } } }], + processedTerminalIds: processed, + }); + + expect(result.unavailableIds).toEqual(['cmd-101']); + }); + + it('keeps a loading overflow id and a terminal overflow command', () => { + const terminal = makeCommand({ id: 'cmd-101', status: 'failed' }); + const result = reconcileCommandStatuses({ + trackedIds: ['cmd-100', 'cmd-101', 'cmd-102'], + batchIds: ['cmd-100'], + perCommandIds: ['cmd-101', 'cmd-102'], + batchCommands: [makeCommand({ id: 'cmd-100', status: 'accepted' })], + batchSettled: true, + perCommandResults: [{ data: terminal }, {}], + processedTerminalIds: processed, + }); + + expect(result.terminalCommands).toEqual([terminal]); + expect(result.unavailableIds).toEqual([]); + }); +}); + +describe('invalidateSecurityAgentCommandObserver (push hint)', () => { + it('invalidates the personal batch and active-command queries', () => { + const invalidateQueries = vi.fn(); + const queryClient = { invalidateQueries }; + + invalidateSecurityAgentCommandObserver( + queryClient as never, + makeTrpcStub() as never, + 'personal' + ); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'getCommandStatuses'], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'listActiveCommands'], + }); + }); + + it('invalidates the organization batch and active-command queries', () => { + const invalidateQueries = vi.fn(); + const queryClient = { invalidateQueries }; + + invalidateSecurityAgentCommandObserver(queryClient as never, makeTrpcStub() as never, 'org_1'); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['organizations', 'securityAgent', 'getCommandStatuses'], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['organizations', 'securityAgent', 'listActiveCommands'], + }); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-commands.ts b/apps/mobile/src/lib/hooks/use-security-agent-commands.ts index c1a180f00b..f8dc24279f 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-commands.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-commands.ts @@ -7,22 +7,39 @@ import { securityCommandIdsKey, type SecurityQueryScope, } from '@kilocode/app-shared/security-agent'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type QueryClient, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { reconcileFirstPage } from '@/lib/query/infinite-retention'; import { scheduleCacheMaintenance } from '@/lib/query/schedule-cache-maintenance'; -import { type SecurityCommand } from '@/lib/security-agent'; +import { + isMissingBatchProcedureError, + reconcileCommandStatuses, + type SecurityCommand, + splitTrackedCommandIds, +} from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; const COMMAND_POLL_INTERVAL_MS = 3000; const EMPTY_COMMANDS: readonly SecurityCommand[] = []; +// Compatibility: per-command polling fallback for servers without getCommandStatuses; remove when all deployed servers serve the batch procedure. +let batchProcedureUnavailable = false; + function sameIds(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((id, index) => id === b[index]); } +// 3s polling only while at least one returned command is still active. +export function activeCommandPollInterval( + commands: readonly SecurityCommand[] | undefined +): number | false { + return commands?.some(command => isActiveSecurityCommand(command)) + ? COMMAND_POLL_INTERVAL_MS + : false; +} + // Registers a freshly created command for background tracking (polling + // invalidation + toast) by the observer for the given scope. Mutation hooks // call this from their `onSuccess` once a command id comes back. @@ -36,11 +53,37 @@ export function trackSecurityAgentCommand( ); } +// Invalidation target for the `security_lifecycle` push consumer: a push that +// changes a command's terminal state must refetch the batch status and the +// active-command list immediately for the affected scope. +export function invalidateSecurityAgentCommandObserver( + queryClient: QueryClient, + trpc: ReturnType, + scope: string +): void { + if (isPersonalSecurityScope(scope)) { + void queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.getCommandStatuses.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.listActiveCommands.queryKey(), + }); + return; + } + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getCommandStatuses.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.listActiveCommands.queryKey(), + }); +} + // Invalidates only the query families mapped to `scopes`, branching on // personal vs. organization procedures (their input shapes are nominally // distinct, so each branch stays fully separate rather than sharing a -// polymorphic "agent" reference). -function invalidateSecurityQueryScopes( +// polymorphic "agent" reference). Exported so the `security_lifecycle` push +// consumer reuses the same scope-key invalidation instead of duplicating it. +export function invalidateSecurityQueryScopes( deps: { trpc: ReturnType; queryClient: QueryClient }, scope: string, scopes: readonly SecurityQueryScope[] @@ -129,15 +172,17 @@ function successMessageForCommand(command: SecurityCommand): string { // Polls for and reconciles background Security Agent commands (sync, // dismiss, analysis, remediation) for one scope ('personal' or an // organization id): recovers in-flight command ids via `listActiveCommands`, -// polls each tracked id via `getCommandStatus` every 3s while active, -// invalidates the affected query families on terminal state, shows one -// toast per terminal id, then drops it from the tracked list. +// polls the tracked ids via one bounded `getCommandStatuses` batch (plus +// per-command overflow beyond 100) every 3s while active, invalidates the +// affected query families on terminal state, shows one toast per terminal +// id, then drops it from the tracked list. export function useSecurityAgentCommands(scope: string): void { const trpc = useTRPC(); const queryClient = useQueryClient(); const isPersonal = isPersonalSecurityScope(scope); const trackedIdsKey = securityCommandIdsKey(scope); const processedTerminalIdsRef = useRef>(new Set()); + const [batchUnavailable, setBatchUnavailable] = useState(batchProcedureUnavailable); const personalActive = useQuery({ ...trpc.securityAgent.listActiveCommands.queryOptions(), @@ -167,7 +212,7 @@ export function useSecurityAgentCommands(scope: string): void { }); useEffect(() => { - // `listActiveCommands` can lag one poll behind `getCommandStatus` and + // `listActiveCommands` can lag one poll behind `getCommandStatuses` and // still report an already-terminal command as active. Filtering those // ids here stops us from re-adding a command the terminal-processing // effect below already toasted and dropped — otherwise its @@ -186,8 +231,32 @@ export function useSecurityAgentCommands(scope: string): void { // eslint-disable-next-line react-hooks/exhaustive-deps -- recoveredCommands is derived per render; comparing by content via sameIds avoids the loop }, [recoveredCommands, trackedIds, queryClient, trackedIdsKey]); + const { batchIds, overflowIds } = splitTrackedCommandIds(trackedIds); + const useBatchPath = !batchUnavailable; + const perCommandIds = useBatchPath ? overflowIds : trackedIds; + + // One bounded batch query for the first 100 ids. Both personal and org + // shapes stay mounted unconditionally; `enabled` picks the active one, the + // same pattern as `personalActive`/`orgActive` above. + const personalBatchStatus = useQuery({ + ...trpc.securityAgent.getCommandStatuses.queryOptions({ commandIds: batchIds }), + enabled: isPersonal && useBatchPath && batchIds.length > 0, + refetchInterval: query => activeCommandPollInterval(query.state.data), + }); + const orgBatchStatus = useQuery({ + ...trpc.organizations.securityAgent.getCommandStatuses.queryOptions({ + organizationId: scope, + commandIds: batchIds, + }), + enabled: !isPersonal && useBatchPath && batchIds.length > 0, + refetchInterval: query => activeCommandPollInterval(query.state.data), + }); + const batchStatusQuery = isPersonal ? personalBatchStatus : orgBatchStatus; + + // Per-command queries cover only the overflow ids beyond 100, or every id + // when the old-server fallback is active. const commandStatusQueries = useQueries({ - queries: trackedIds.map(commandId => ({ + queries: perCommandIds.map(commandId => ({ ...(isPersonal ? trpc.securityAgent.getCommandStatus.queryOptions({ commandId }) : trpc.organizations.securityAgent.getCommandStatus.queryOptions({ @@ -202,18 +271,23 @@ export function useSecurityAgentCommands(scope: string): void { }); useEffect(() => { - const unavailableIds = commandStatusQueries.flatMap((query, index) => { - const id = trackedIds[index]; - return query.error?.data?.code === 'NOT_FOUND' && id ? [id] : []; + if (isMissingBatchProcedureError(batchStatusQuery.error)) { + batchProcedureUnavailable = true; + setBatchUnavailable(true); + } + }, [batchStatusQuery.error]); + + useEffect(() => { + const { terminalCommands, unavailableIds } = reconcileCommandStatuses({ + trackedIds, + batchIds: useBatchPath ? batchIds : [], + perCommandIds, + batchCommands: batchStatusQuery.data, + batchSettled: useBatchPath && batchStatusQuery.data !== undefined, + perCommandResults: commandStatusQueries, + processedTerminalIds: processedTerminalIdsRef.current, }); - const terminalCommands = commandStatusQueries - .map(query => query.data) - .filter( - (command): command is SecurityCommand => - command !== undefined && - !isActiveSecurityCommand(command) && - !processedTerminalIdsRef.current.has(command.id) - ); + if (terminalCommands.length === 0 && unavailableIds.length === 0) { return; } @@ -248,5 +322,19 @@ export function useSecurityAgentCommands(scope: string): void { trackedIds.filter(id => !completedIds.has(id)) ); // eslint-disable-next-line react-hooks/exhaustive-deps -- trpc/queryClient are stable; trackedIds/scope drive the effect body directly - }, [commandStatusQueries, trackedIds, scope, trackedIdsKey]); + }, [ + batchStatusQuery.data, + commandStatusQueries, + trackedIds, + scope, + trackedIdsKey, + useBatchPath, + batchIds, + perCommandIds, + ]); +} + +// Test seam: resets the module-level fallback latch between tests. +export function _resetBatchProcedureAvailabilityForTests(): void { + batchProcedureUnavailable = false; } diff --git a/apps/mobile/src/lib/hooks/use-security-findings.test.ts b/apps/mobile/src/lib/hooks/use-security-findings.test.ts index a9de592e43..fc4a98bfa8 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.test.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.test.ts @@ -28,6 +28,7 @@ const hoistedKeys = vi.hoisted(() => ({ })); const trackCommandMock = vi.hoisted(() => vi.fn()); +const invalidateQueriesMock = vi.hoisted(() => vi.fn()); const toastErrorMock = vi.fn(); vi.mock('expo-crypto', () => ({ @@ -115,7 +116,7 @@ vi.mock('@tanstack/react-query', () => ({ return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false, error: null }; }, useQueryClient: () => ({ - invalidateQueries: vi.fn(), + invalidateQueries: invalidateQueriesMock, setQueryData: vi.fn(), getQueryData: vi.fn(), cancelQueries: vi.fn(), @@ -257,6 +258,8 @@ describe('useStartSecurityAnalysis (P1-B-18 forceSandbox)', () => { lastCapturedOptions = null; personalStartAnalysisMutateMock.mockReset(); orgStartAnalysisMutateMock.mockReset(); + trackCommandMock.mockClear(); + invalidateQueriesMock.mockClear(); }); it('always sends forceSandbox: true on a personal analysis start', async () => { @@ -284,6 +287,17 @@ describe('useStartSecurityAnalysis (P1-B-18 forceSandbox)', () => { forceSandbox: true, }); }); + + it('onSuccess with no commandId skips command tracking but still invalidates queries', () => { + useStartSecurityAnalysis('personal'); + + // The invalidation calls run synchronously while the Promise.all array is + // built, so no await is needed to observe them. + lastCapturedOptions?.onSuccess?.({ commandId: undefined }, { findingId: FINDING_ID }); + + expect(trackCommandMock).not.toHaveBeenCalled(); + expect(invalidateQueriesMock).toHaveBeenCalled(); + }); }); describe('dismissFindingIntentFingerprint (P1-A-08e changed-input)', () => { diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index 75a64fcdc3..189db62f22 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -190,7 +190,9 @@ export function useStartSecurityAnalysis(scope: string) { toast.error(error.message); }, onSuccess: async (result, vars) => { - trackSecurityAgentCommand(queryClient, scope, result.commandId); + if (result.commandId) { + trackSecurityAgentCommand(queryClient, scope, result.commandId); + } if (isPersonalSecurityScope(scope)) { await Promise.all([ queryClient.invalidateQueries({ diff --git a/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.test.ts b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.test.ts new file mode 100644 index 0000000000..c5786aafff --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + invalidateAllSecurityLifecycleScopes, + invalidateSecurityLifecycleScope, + subscribeToSecurityLifecycleInvalidation, +} from './use-security-lifecycle-invalidation'; +import type * as SecurityAgentCommandsModule from './use-security-agent-commands'; + +const mocks = vi.hoisted(() => ({ + addNotificationReceivedListener: vi.fn(), + appStateAddEventListener: vi.fn(), + onlineSubscribe: vi.fn(), + parseNotificationData: vi.fn(), + reconcileFirstPage: vi.fn(), + scheduleCacheMaintenance: vi.fn((run: () => void) => { + run(); + }), + invalidateSecurityAgentCommandObserver: vi.fn(), +})); + +vi.mock('expo-notifications', () => ({ + addNotificationReceivedListener: mocks.addNotificationReceivedListener, +})); + +vi.mock('@tanstack/react-query', () => ({ + onlineManager: { subscribe: mocks.onlineSubscribe }, + useQueryClient: vi.fn(), +})); + +vi.mock('react-native', () => ({ + AppState: { addEventListener: mocks.appStateAddEventListener }, +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({}), +})); + +vi.mock('@/lib/notifications', () => ({ + parseNotificationData: mocks.parseNotificationData, +})); + +vi.mock('@/lib/query/infinite-retention', () => ({ + reconcileFirstPage: mocks.reconcileFirstPage, +})); + +vi.mock('@/lib/query/schedule-cache-maintenance', () => ({ + scheduleCacheMaintenance: mocks.scheduleCacheMaintenance, +})); + +vi.mock('@/lib/hooks/use-security-agent-commands', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + invalidateSecurityAgentCommandObserver: mocks.invalidateSecurityAgentCommandObserver, + }; +}); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +type OrgKey = (input?: { organizationId: string }) => unknown[]; + +type TrpcStub = { + securityAgent: { + listFindings: { queryKey: () => string[] }; + getFinding: { queryKey: () => string[] }; + getAnalysis: { queryKey: () => string[] }; + getCommandStatuses: { queryKey: () => string[] }; + listActiveCommands: { queryKey: () => string[] }; + }; + organizations: { + securityAgent: { + listFindings: { queryKey: OrgKey }; + getFinding: { queryKey: OrgKey }; + getAnalysis: { queryKey: OrgKey }; + getCommandStatuses: { queryKey: () => string[] }; + listActiveCommands: { queryKey: () => string[] }; + }; + }; +}; + +function orgKey(name: string): OrgKey { + return input => + input + ? ['organizations', 'securityAgent', name, input] + : ['organizations', 'securityAgent', name]; +} + +function makeTrpcStub(): TrpcStub { + return { + securityAgent: { + listFindings: { queryKey: () => ['securityAgent', 'listFindings'] }, + getFinding: { queryKey: () => ['securityAgent', 'getFinding'] }, + getAnalysis: { queryKey: () => ['securityAgent', 'getAnalysis'] }, + getCommandStatuses: { queryKey: () => ['securityAgent', 'getCommandStatuses'] }, + listActiveCommands: { queryKey: () => ['securityAgent', 'listActiveCommands'] }, + }, + organizations: { + securityAgent: { + listFindings: { queryKey: orgKey('listFindings') }, + getFinding: { queryKey: orgKey('getFinding') }, + getAnalysis: { queryKey: orgKey('getAnalysis') }, + getCommandStatuses: { + queryKey: () => ['organizations', 'securityAgent', 'getCommandStatuses'], + }, + listActiveCommands: { + queryKey: () => ['organizations', 'securityAgent', 'listActiveCommands'], + }, + }, + }, + }; +} + +function makeQueryClient() { + return { invalidateQueries: vi.fn() }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.addNotificationReceivedListener.mockReset(); + mocks.appStateAddEventListener.mockReset(); + mocks.onlineSubscribe.mockReset(); + mocks.parseNotificationData.mockReset(); +}); + +describe('invalidateSecurityLifecycleScope', () => { + it.each([ + { + scope: 'personal', + findingsKey: ['securityAgent', 'listFindings'], + findingKey: ['securityAgent', 'getFinding'], + analysisKey: ['securityAgent', 'getAnalysis'], + }, + { + scope: 'org_1', + findingsKey: ['organizations', 'securityAgent', 'listFindings', { organizationId: 'org_1' }], + findingKey: ['organizations', 'securityAgent', 'getFinding', { organizationId: 'org_1' }], + analysisKey: ['organizations', 'securityAgent', 'getAnalysis', { organizationId: 'org_1' }], + }, + ])( + 'invalidates the $scope findings, finding-details, analysis, and command-status queries', + ({ scope, findingsKey, findingKey, analysisKey }) => { + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const deps = { trpc, queryClient }; + + invalidateSecurityLifecycleScope(deps as never, scope); + + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, findingsKey); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: findingKey }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: analysisKey }); + expect(mocks.invalidateSecurityAgentCommandObserver).toHaveBeenCalledWith( + queryClient, + trpc, + scope + ); + } + ); +}); + +describe('invalidateAllSecurityLifecycleScopes', () => { + it('invalidates the personal and organization families with no scope', () => { + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const deps = { trpc, queryClient }; + + invalidateAllSecurityLifecycleScopes(deps as never); + + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'securityAgent', + 'listFindings', + ]); + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'organizations', + 'securityAgent', + 'listFindings', + ]); + + for (const queryKey of [ + ['securityAgent', 'getFinding'], + ['securityAgent', 'getAnalysis'], + ['securityAgent', 'getCommandStatuses'], + ['securityAgent', 'listActiveCommands'], + ['organizations', 'securityAgent', 'getFinding'], + ['organizations', 'securityAgent', 'getAnalysis'], + ['organizations', 'securityAgent', 'getCommandStatuses'], + ['organizations', 'securityAgent', 'listActiveCommands'], + ]) { + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey }); + } + }); +}); + +describe('subscribeToSecurityLifecycleInvalidation', () => { + type ReceivedListener = (notification: { request: { content: { data: unknown } } }) => void; + type AppStateListener = (state: string) => void; + type OnlineListener = (online: boolean) => void; + + function captureListeners() { + let receivedListener: ReceivedListener | undefined = undefined; + let appStateListener: AppStateListener | undefined = undefined; + let onlineListener: OnlineListener | undefined = undefined; + + mocks.addNotificationReceivedListener.mockImplementation((listener: ReceivedListener) => { + receivedListener = listener; + return { remove: vi.fn() }; + }); + mocks.appStateAddEventListener.mockImplementation( + (_event: string, listener: AppStateListener) => { + appStateListener = listener; + return { remove: vi.fn() }; + } + ); + mocks.onlineSubscribe.mockImplementation((listener: OnlineListener) => { + onlineListener = listener; + return vi.fn(); + }); + + return { + received: (data: unknown) => { + receivedListener?.({ request: { content: { data } } }); + }, + appState: (state: string) => { + appStateListener?.(state); + }, + online: (online: boolean) => { + onlineListener?.(online); + }, + }; + } + + it('invalidates the affected scope on a foreground security_lifecycle receipt', () => { + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const deps = { trpc, queryClient }; + const listeners = captureListeners(); + + subscribeToSecurityLifecycleInvalidation(deps as never); + + mocks.parseNotificationData.mockReturnValue({ + type: 'security_lifecycle', + event: 'analysis_completed', + findingId: 'f-1', + scope: 'org_9', + }); + listeners.received({ type: 'security_lifecycle' }); + + expect(mocks.invalidateSecurityAgentCommandObserver).toHaveBeenCalledWith( + queryClient, + trpc, + 'org_9' + ); + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'organizations', + 'securityAgent', + 'listFindings', + { organizationId: 'org_9' }, + ]); + }); + + it('drops an unparseable or non-lifecycle payload without invalidating', () => { + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const deps = { trpc, queryClient }; + const listeners = captureListeners(); + + subscribeToSecurityLifecycleInvalidation(deps as never); + + // Unknown event value: Zod parse returns null. + mocks.parseNotificationData.mockReturnValue(null); + listeners.received({ type: 'security_lifecycle', event: 'sla_warning' }); + expect(mocks.invalidateSecurityAgentCommandObserver).not.toHaveBeenCalled(); + + // A visible finding push is not a lifecycle event. + mocks.parseNotificationData.mockReturnValue({ + type: 'security_finding', + findingId: 'f-1', + scope: 'personal', + }); + listeners.received({ type: 'security_finding' }); + expect(mocks.invalidateSecurityAgentCommandObserver).not.toHaveBeenCalled(); + }); + + it('invalidates every family on AppState active and on reconnect', () => { + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const deps = { trpc, queryClient }; + const listeners = captureListeners(); + + subscribeToSecurityLifecycleInvalidation(deps as never); + + listeners.appState('active'); + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'securityAgent', + 'listFindings', + ]); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'getCommandStatuses'], + }); + + mocks.reconcileFirstPage.mockClear(); + queryClient.invalidateQueries.mockClear(); + + listeners.online(true); + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'securityAgent', + 'listFindings', + ]); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'getCommandStatuses'], + }); + + // Offline is not a recovery source. + mocks.reconcileFirstPage.mockClear(); + queryClient.invalidateQueries.mockClear(); + listeners.online(false); + expect(mocks.reconcileFirstPage).not.toHaveBeenCalled(); + expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + }); + + it('removes all three subscriptions on cleanup', () => { + const removeNotification = vi.fn(); + const removeAppState = vi.fn(); + const removeOnline = vi.fn(); + + mocks.addNotificationReceivedListener.mockReturnValue({ remove: removeNotification }); + mocks.appStateAddEventListener.mockReturnValue({ remove: removeAppState }); + mocks.onlineSubscribe.mockReturnValue(removeOnline); + + const trpc = makeTrpcStub(); + const queryClient = makeQueryClient(); + const cleanup = subscribeToSecurityLifecycleInvalidation({ + trpc: trpc as never, + queryClient: queryClient as never, + }); + + cleanup(); + + expect(removeNotification).toHaveBeenCalled(); + expect(removeAppState).toHaveBeenCalled(); + expect(removeOnline).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.ts b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.ts new file mode 100644 index 0000000000..2b3d452857 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.ts @@ -0,0 +1,129 @@ +import * as Notifications from 'expo-notifications'; +import { onlineManager, type QueryClient, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { AppState } from 'react-native'; + +import { + invalidateSecurityAgentCommandObserver, + invalidateSecurityQueryScopes, +} from '@/lib/hooks/use-security-agent-commands'; +import { parseNotificationData } from '@/lib/notifications'; +import { reconcileFirstPage } from '@/lib/query/infinite-retention'; +import { scheduleCacheMaintenance } from '@/lib/query/schedule-cache-maintenance'; +import { useTRPC } from '@/lib/trpc'; + +type SecurityLifecycleInvalidationDeps = { + trpc: ReturnType; + queryClient: QueryClient; +}; + +/** + * Invalidates the findings list, finding details, and command-status queries + * for one scope after a `security_lifecycle` push changed that scope's state. + * Reuses `invalidateSecurityQueryScopes` for the findings/finding-details + * scope keys, then adds the command-status invalidation on top. + */ +export function invalidateSecurityLifecycleScope( + deps: SecurityLifecycleInvalidationDeps, + scope: string +): void { + const { trpc, queryClient } = deps; + + invalidateSecurityQueryScopes({ trpc, queryClient }, scope, [ + 'findings', + 'findingDetails', + 'analysis', + ]); + invalidateSecurityAgentCommandObserver(queryClient, trpc, scope); +} + +/** + * Invalidates the findings, finding-details, and command-status families for + * every scope (personal and all organizations) with no scope in hand. Used on + * AppState return to `active` and on React Query reconnect: a missed push must + * not leave findings stale, so the whole family refetches from the server. + */ +export function invalidateAllSecurityLifecycleScopes( + deps: SecurityLifecycleInvalidationDeps +): void { + const { trpc, queryClient } = deps; + + scheduleCacheMaintenance(() => { + reconcileFirstPage(queryClient, trpc.securityAgent.listFindings.queryKey()); + }); + void queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getFinding.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getAnalysis.queryKey() }); + void queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.getCommandStatuses.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.listActiveCommands.queryKey(), + }); + + scheduleCacheMaintenance(() => { + reconcileFirstPage(queryClient, trpc.organizations.securityAgent.listFindings.queryKey()); + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getFinding.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getAnalysis.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getCommandStatuses.queryKey(), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.listActiveCommands.queryKey(), + }); +} + +/** + * Registers the three recovery sources and returns a single cleanup function: + * foreground push receipt (scope-specific), AppState return to `active`, and + * React Query reconnect (both family-wide). The notification data is + * Zod-parsed first, so an old or unknown event value is dropped without any + * invalidation. + */ +export function subscribeToSecurityLifecycleInvalidation( + deps: SecurityLifecycleInvalidationDeps +): () => void { + const notificationSubscription = Notifications.addNotificationReceivedListener(notification => { + const data = parseNotificationData(notification.request.content.data); + if (data?.type !== 'security_lifecycle') { + return; + } + invalidateSecurityLifecycleScope(deps, data.scope); + }); + + const appStateSubscription = AppState.addEventListener('change', nextState => { + if (nextState === 'active') { + invalidateAllSecurityLifecycleScopes(deps); + } + }); + + const unsubscribeOnline = onlineManager.subscribe(online => { + if (online) { + invalidateAllSecurityLifecycleScopes(deps); + } + }); + + return () => { + notificationSubscription.remove(); + appStateSubscription.remove(); + unsubscribeOnline(); + }; +} + +/** + * Mounted by the authed app layout. Owns the query client that + * `notifications.ts` (the display/tap handler) does not have. + */ +export function useSecurityLifecycleInvalidation(): void { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + useEffect( + () => subscribeToSecurityLifecycleInvalidation({ trpc, queryClient }), + [trpc, queryClient] + ); +} diff --git a/apps/mobile/src/lib/notification-path.test.ts b/apps/mobile/src/lib/notification-path.test.ts index ee20286984..bb7a9376f2 100644 --- a/apps/mobile/src/lib/notification-path.test.ts +++ b/apps/mobile/src/lib/notification-path.test.ts @@ -83,6 +83,41 @@ describe('notificationPathForData', () => { }) ).toBe('/(app)/(tabs)/(3_profile)/security-agent/org-xyz/findings/finding-2?via=push'); }); + + it('routes every security_lifecycle event value to the finding detail path', () => { + const events = [ + 'analysis_completed', + 'analysis_failed', + 'remediation_queued', + 'remediation_pr_opened', + 'remediation_failed', + 'remediation_blocked', + 'remediation_no_changes_needed', + 'remediation_cancelled', + ] as const; + + for (const event of events) { + expect( + notificationPathForData({ + type: 'security_lifecycle', + event, + findingId: 'finding-3', + scope: 'personal', + }) + ).toBe('/(app)/(tabs)/(3_profile)/security-agent/personal/findings/finding-3?via=push'); + } + }); + + it('routes security_lifecycle notifications for an organization scope', () => { + expect( + notificationPathForData({ + type: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-4', + scope: 'org-xyz', + }) + ).toBe('/(app)/(tabs)/(3_profile)/security-agent/org-xyz/findings/finding-4?via=push'); + }); }); describe('pushDataSchema', () => { @@ -196,4 +231,15 @@ describe('pushDataSchema', () => { }).success ).toBe(false); }); + + it('rejects a security_lifecycle payload with an unknown event value', () => { + expect( + pushDataSchema.safeParse({ + type: 'security_lifecycle', + event: 'sla_warning', + findingId: 'finding-1', + scope: 'org-xyz', + }).success + ).toBe(false); + }); }); diff --git a/apps/mobile/src/lib/notification-path.ts b/apps/mobile/src/lib/notification-path.ts index 09ed680ae4..740aa31ae8 100644 --- a/apps/mobile/src/lib/notification-path.ts +++ b/apps/mobile/src/lib/notification-path.ts @@ -16,8 +16,12 @@ export function notificationPathForData(data: PushData): string { case 'low_balance': { return `/(app)/(tabs)/(3_profile)/organization/credit-activity?org=${data.organizationId}&via=push`; } - case 'security_finding': { + case 'security_finding': + case 'security_lifecycle': { // getSecurityAgentPath returns Href; coerce to string for query append (cast style of security-agent.ts). + // security_lifecycle reuses the finding detail path: every WS1 event + // value carries findingId + scope, and finding creation keeps the + // visible security_finding push. const base = getSecurityAgentPath(data.scope, `findings/${data.findingId}`) as string; return `${base}?via=push`; } diff --git a/apps/mobile/src/lib/security-agent.ts b/apps/mobile/src/lib/security-agent.ts index 4245f28cfd..98d280b15d 100644 --- a/apps/mobile/src/lib/security-agent.ts +++ b/apps/mobile/src/lib/security-agent.ts @@ -1,3 +1,4 @@ +import { isActiveSecurityCommand } from '@kilocode/app-shared/security-agent'; import { type inferRouterInputs, type inferRouterOutputs, @@ -21,8 +22,104 @@ export type FlattenedSecurityAgentConfig = { export type SecurityFinding = RouterOutputs['securityAgent']['getFinding']; export type SecurityAnalysis = RouterOutputs['securityAgent']['getAnalysis']; export type SecurityCommand = NonNullable; +export type SecurityCommandBatch = RouterOutputs['securityAgent']['getCommandStatuses']; export function getSecurityAgentPath(scope: string, suffix = ''): Href { const path = `/(app)/(tabs)/(3_profile)/security-agent/${scope}`; return (suffix ? `${path}/${suffix}` : path) as Href; } + +// The server batch procedure is bounded to 100 ids. The recovery source is +// limited server-side, but the tracked-id merge unions in-session mutation ids +// on top, so the total can exceed this cap. +export const BATCH_COMMAND_LIMIT = 100; + +// The tRPC procedure-missing signature: a NOT_FOUND whose message matches +// `No "query"-procedure`. A bare NOT_FOUND is the per-command purge path and +// must never engage the old-server fallback. +export function isMissingBatchProcedureError(error: unknown): boolean { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- the tRPC client error is an untyped boundary value; decode its shape before branching + if (typeof error !== 'object' || error === null) { + return false; + } + const err = error as { message?: unknown; data?: { code?: unknown } }; + return ( + err.data?.code === 'NOT_FOUND' && + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- decode the message field before matching the signature + typeof err.message === 'string' && + err.message.includes('No "query"-procedure') + ); +} + +// Splits the tracked ids into the server-bounded batch slice (first 100) and +// the per-command overflow (the rest). +export function splitTrackedCommandIds(trackedIds: readonly string[]) { + return { + batchIds: trackedIds.slice(0, BATCH_COMMAND_LIMIT), + overflowIds: trackedIds.slice(BATCH_COMMAND_LIMIT), + }; +} + +export type CommandStatusQueryResult = { + data?: SecurityCommand; + error?: { data?: { code?: string } | null } | null; +}; + +export type CommandStatusReconciliation = { + terminalCommands: SecurityCommand[]; + unavailableIds: string[]; +}; + +// Builds a Map from the batch array plus per-command results, +// then splits the tracked ids into terminal commands (present and inactive) +// and unavailable ids (absent once the source settled, or NOT_FOUND). +export function reconcileCommandStatuses(args: { + trackedIds: readonly string[]; + batchIds: readonly string[]; + perCommandIds: readonly string[]; + batchCommands: SecurityCommandBatch | undefined; + batchSettled: boolean; + perCommandResults: readonly CommandStatusQueryResult[]; + processedTerminalIds: ReadonlySet; +}): CommandStatusReconciliation { + const { + trackedIds, + batchIds, + perCommandIds, + batchCommands, + batchSettled, + perCommandResults, + processedTerminalIds, + } = args; + const commandsById = new Map(); + for (const command of batchCommands ?? []) { + commandsById.set(command.id, command); + } + for (const result of perCommandResults) { + if (result.data) { + commandsById.set(result.data.id, result.data); + } + } + + const terminalCommands: SecurityCommand[] = []; + for (const command of commandsById.values()) { + if (!isActiveSecurityCommand(command) && !processedTerminalIds.has(command.id)) { + terminalCommands.push(command); + } + } + + const unavailableIds = trackedIds.flatMap(id => { + if (commandsById.has(id) || processedTerminalIds.has(id)) { + return []; + } + if (batchIds.includes(id)) { + // The batch omits unknown ids; purge only once it settled without them. + return batchSettled ? [id] : []; + } + const index = perCommandIds.indexOf(id); + const result = index === -1 ? undefined : perCommandResults.at(index); + return result?.error?.data?.code === 'NOT_FOUND' ? [id] : []; + }); + + return { terminalCommands, unavailableIds }; +} diff --git a/apps/mobile/src/lib/session-attention.test.ts b/apps/mobile/src/lib/session-attention.test.ts index f5c170fefb..f3fe97c091 100644 --- a/apps/mobile/src/lib/session-attention.test.ts +++ b/apps/mobile/src/lib/session-attention.test.ts @@ -1,19 +1,63 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +/* eslint-disable max-lines -- cohesive suite for the ack state machine, durable persistence, expiry, and hydration contracts */ +/* eslint-disable require-await, @typescript-eslint/require-await -- the fake KV factories settle without await because they resolve immediately */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// The session-attention module lazy-loads the native encrypted-kv chain; the +// fake below is an in-memory Map-backed KV so persistence tests run in node. +const kvStore = new Map(); + +const kvMock = vi.hoisted(() => ({ + getItem: vi.fn(async (_scope: string, _k: string): Promise => null), + setItem: vi.fn(async (_scope: string, _k: string, _v: string): Promise => undefined), +})); + +vi.mock('@/lib/persist/encrypted-kv', () => kvMock); + +/* eslint-disable import/first */ +import { SESSION_ATTENTION_KEY } from '@/lib/storage-keys'; import { + __flushSessionAttentionWritesForTests, + __hydrateSessionAttentionForTests, + __peekSessionAttentionEntryForTests, __peekSessionAttentionForTests, __resetSessionAttentionForTests, ackSessionAttention, getRevisionSnapshot, isAttentionAcked, reconcileSessionAttention, + SESSION_ATTENTION_EXPIRY_MS, sessionNeedsInput, shouldShowNeedsInput, subscribe, } from './session-attention'; +/* eslint-enable import/first */ + +// Matches the module's internal item key for the single entries blob. +const ATTENTION_ENTRY_KEY = 'entries'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function storageKey(scope: string, k: string): string { + return `${scope}\u0000${k}`; +} + +function seedAttentionKv(entries: unknown[]): void { + kvStore.set(storageKey(SESSION_ATTENTION_KEY, ATTENTION_ENTRY_KEY), JSON.stringify(entries)); +} beforeEach(() => { + vi.clearAllMocks(); + kvStore.clear(); __resetSessionAttentionForTests(); + kvMock.getItem.mockImplementation(async (scope, k) => kvStore.get(storageKey(scope, k)) ?? null); + kvMock.setItem.mockImplementation(async (scope, k, v) => { + kvStore.set(storageKey(scope, k), v); + }); +}); + +afterEach(async () => { + await __flushSessionAttentionWritesForTests(); + vi.useRealTimers(); }); describe('sessionNeedsInput', () => { @@ -196,7 +240,7 @@ describe('ack store state machine', () => { unsubscribe(); }); - it('reconcile with attention status and a resolved entry is a no-op (does not bump revision)', () => { + it('reconcile with attention status and a resolved entry is a no-op for the same raise', () => { ackSessionAttention('s1'); reconcileSessionAttention('s1', 'question', 'R1'); // now entry.raiseId === 'R1' @@ -209,15 +253,281 @@ describe('ack store state machine', () => { reconcileSessionAttention('s1', 'question', 'R1'); expect(getRevisionSnapshot()).toBe(before); expect(listener).not.toHaveBeenCalled(); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + + unsubscribe(); + }); +}); + +describe('entry shape and re-raise', () => { + it('resolves a pending entry with the observed raise and ack metadata', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + const entry = __peekSessionAttentionEntryForTests('s1'); + expect(entry).toMatchObject({ raiseId: 'R1', status: 'question' }); + expect(entry?.ackedAt).toBeTypeOf('number'); + expect(entry?.expiresAt).toBe((entry?.ackedAt ?? 0) + SESSION_ATTENTION_EXPIRY_MS); + }); - // different raiseId → resolved entry blocks absorb, no change + it('replaces the raise and clears the ack on a same-session re-raise', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + + // a new status_updated_at is a new raise: the badge returns reconcileSessionAttention('s1', 'question', 'R2'); - expect(getRevisionSnapshot()).toBe(before); - expect(listener).not.toHaveBeenCalled(); + expect(isAttentionAcked('s1', 'R1')).toBe(false); + expect(isAttentionAcked('s1', 'R2')).toBe(false); + expect(__peekSessionAttentionEntryForTests('s1')).toEqual({ + raiseId: 'R2', + status: 'question', + ackedAt: null, + expiresAt: null, + }); + }); + + it('acking a re-raised entry re-pends it and hides the new raise', () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + // re-raise + reconcileSessionAttention('s1', 'question', 'R2'); + expect(isAttentionAcked('s1', 'R2')).toBe(false); + + // user answers the new raise + ackSessionAttention('s1'); + expect(isAttentionAcked('s1', 'R2')).toBe(true); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: null }); + }); +}); + +describe('durable persistence', () => { + it('round-trips acks across a simulated restart', async () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + await __flushSessionAttentionWritesForTests(); + + // Simulated restart: clear the in-memory store, then re-hydrate from KV. + __resetSessionAttentionForTests(); + await __hydrateSessionAttentionForTests(); + expect(isAttentionAcked('s1', 'R1')).toBe(true); expect(isAttentionAcked('s1', 'R2')).toBe(false); + expect(__peekSessionAttentionEntryForTests('s1')).toEqual({ + raiseId: 'R1', + status: 'question', + ackedAt: expect.any(Number), + expiresAt: expect.any(Number), + }); + }); - unsubscribe(); + it('restores a pending ack as pending across a restart', async () => { + ackSessionAttention('s1'); + await __flushSessionAttentionWritesForTests(); + + __resetSessionAttentionForTests(); + await __hydrateSessionAttentionForTests(); + + // A pending ack hides any raise after restart. + expect(isAttentionAcked('s1', 'R1')).toBe(true); + expect(isAttentionAcked('s1', 'R2')).toBe(true); + }); + + it('persists a deleted entry as gone across a restart', async () => { + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + // delete + reconcileSessionAttention('s1', 'busy', null); + await __flushSessionAttentionWritesForTests(); + + __resetSessionAttentionForTests(); + await __hydrateSessionAttentionForTests(); + + expect(__peekSessionAttentionForTests('s1')).toBeUndefined(); + }); + + it('persists a write during the hydration window without erasing the hydrated entry', async () => { + const now = Date.now(); + const persisted = JSON.stringify([ + { + sessionId: 's1', + raiseId: 'R1', + status: 'question', + ackedAt: now, + expiresAt: now + SESSION_ATTENTION_EXPIRY_MS, + }, + ]); + + // Hold the KV read open so the write can land mid-hydration. + const readGate = Promise.withResolvers(); + kvMock.getItem.mockReturnValueOnce(readGate.promise); + + __resetSessionAttentionForTests(); + const hydration = __hydrateSessionAttentionForTests(); + + // A write for a different session lands while hydration is still reading. + ackSessionAttention('s2'); + + // Release the stale persisted read. + readGate.resolve(persisted); + await hydration; + await __flushSessionAttentionWritesForTests(); + + // The persisted blob holds both the hydrated entry and the fresh entry. + const stored = JSON.parse( + kvStore.get(storageKey(SESSION_ATTENTION_KEY, ATTENTION_ENTRY_KEY)) ?? '[]' + ) as { sessionId: string }[]; + expect(stored.map(entry => entry.sessionId).toSorted()).toEqual(['s1', 's2']); + }); + + it('keeps in-memory behavior when a KV write fails and retries on the next bump', async () => { + kvMock.setItem.mockRejectedValueOnce(new Error('disk full')); + ackSessionAttention('s1'); + // In-memory store is authoritative: the badge hides immediately. + expect(isAttentionAcked('s1', 'R1')).toBe(true); + await __flushSessionAttentionWritesForTests(); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: null }); + + // The next bump retries the write. + reconcileSessionAttention('s1', 'question', 'R1'); + await __flushSessionAttentionWritesForTests(); + expect(kvMock.setItem).toHaveBeenCalledTimes(2); + expect(kvStore.get(storageKey(SESSION_ATTENTION_KEY, ATTENTION_ENTRY_KEY))).toBeDefined(); + }); + + it('starts empty when hydration fails, and the in-memory store still works', async () => { + seedAttentionKv([ + { + sessionId: 's1', + raiseId: 'R1', + status: 'question', + ackedAt: Date.now(), + expiresAt: Date.now() + SESSION_ATTENTION_EXPIRY_MS, + }, + ]); + kvMock.getItem.mockRejectedValueOnce(new Error('corrupt')); + __resetSessionAttentionForTests(); + await __hydrateSessionAttentionForTests(); + + expect(__peekSessionAttentionForTests('s1')).toBeUndefined(); + + ackSessionAttention('s1'); + expect(isAttentionAcked('s1', 'R1')).toBe(true); + }); +}); + +describe('expiry', () => { + it('drops expired entries at hydration', async () => { + const now = Date.now(); + seedAttentionKv([ + { + sessionId: 'expired', + raiseId: 'R1', + status: 'question', + ackedAt: now - 8 * DAY_MS, + expiresAt: now - DAY_MS, + }, + { + sessionId: 'fresh', + raiseId: 'R2', + status: 'permission', + ackedAt: now, + expiresAt: now + SESSION_ATTENTION_EXPIRY_MS, + }, + ]); + __resetSessionAttentionForTests(); + await __hydrateSessionAttentionForTests(); + + expect(__peekSessionAttentionForTests('expired')).toBeUndefined(); + expect(__peekSessionAttentionForTests('fresh')).toEqual({ raiseId: 'R2' }); + }); + + it('drops expired entries on reconcile', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + ackSessionAttention('s1'); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: 'R1' }); + + // 9 days later the ack has expired. + vi.setSystemTime(new Date('2026-01-10T00:00:00Z')); + reconcileSessionAttention('s1', 'question', 'R1'); + expect(__peekSessionAttentionForTests('s1')).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('hydration gating', () => { + it('renders badges from server status until hydration completes', async () => { + const now = Date.now(); + seedAttentionKv([ + { + sessionId: 's1', + raiseId: 'R1', + status: 'question', + ackedAt: now, + expiresAt: now + SESSION_ATTENTION_EXPIRY_MS, + }, + ]); + __resetSessionAttentionForTests(); + const hydration = __hydrateSessionAttentionForTests(); + + // Hydration is in flight: the store is still empty, so the badge derives + // from server status (no stale ack suppression). + expect(isAttentionAcked('s1', 'R1')).toBe(false); + expect( + shouldShowNeedsInput({ + status: 'question', + raiseId: 'R1', + isAcked: isAttentionAcked('s1', 'R1'), + }) + ).toBe(true); + + await hydration; + + // After hydration the restored ack suppresses its raise. + expect(isAttentionAcked('s1', 'R1')).toBe(true); + expect( + shouldShowNeedsInput({ + status: 'question', + raiseId: 'R1', + isAcked: isAttentionAcked('s1', 'R1'), + }) + ).toBe(false); + }); + + it('does not revert a fresh ack committed during the hydration window', async () => { + const now = Date.now(); + const persisted = JSON.stringify([ + { + sessionId: 's1', + raiseId: 'R1', + status: 'question', + ackedAt: now, + expiresAt: now + SESSION_ATTENTION_EXPIRY_MS, + }, + ]); + + // Hold the KV read open so the ack can land mid-hydration. + const readGate = Promise.withResolvers(); + kvMock.getItem.mockReturnValueOnce(readGate.promise); + + __resetSessionAttentionForTests(); + const hydration = __hydrateSessionAttentionForTests(); + + // The fresh ack lands while hydration is still reading. + ackSessionAttention('s1'); + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: null }); + + // Release the stale persisted read. + readGate.resolve(persisted); + await hydration; + + // The fresh ack survives: still pending, not the persisted resolved entry. + expect(__peekSessionAttentionForTests('s1')).toEqual({ raiseId: null }); + expect(isAttentionAcked('s1', 'R1')).toBe(true); }); }); @@ -272,7 +582,7 @@ describe('revision snapshot and listener notification', () => { unsubscribe(); }); - it('bumps revision on mutating reconciles (resolve, delete) and stays stable on no-ops', () => { + it('bumps revision on mutating reconciles and stays stable on no-ops', () => { const listener = vi.fn<() => void>(); const unsubscribe = subscribe(listener); @@ -293,20 +603,23 @@ describe('revision snapshot and listener notification', () => { const afterMutations = getRevisionSnapshot(); - // no-op reconciles: no entry → no change; resolved entry → no change + // no-op reconciles: no entry → no change; resolved entry + same raise → no change reconcileSessionAttention('s2', 'busy', null); reconcileSessionAttention('s1', 'question', 'R1'); - reconcileSessionAttention('s1', 'question', 'R2'); - reconcileSessionAttention('s1', 'question', null); expect(getRevisionSnapshot()).toBe(afterMutations); expect(listener).toHaveBeenCalledTimes(mutations); - // delete → mutation - reconcileSessionAttention('s1', 'busy', null); + // re-raise → mutation + reconcileSessionAttention('s1', 'question', 'R2'); expect(getRevisionSnapshot()).toBe(afterMutations + 1); expect(listener).toHaveBeenCalledTimes(mutations + 1); + // delete → mutation + reconcileSessionAttention('s1', 'busy', null); + expect(getRevisionSnapshot()).toBe(afterMutations + 2); + expect(listener).toHaveBeenCalledTimes(mutations + 2); + unsubscribe(); }); diff --git a/apps/mobile/src/lib/session-attention.ts b/apps/mobile/src/lib/session-attention.ts index 5608b9223f..fda4285df2 100644 --- a/apps/mobile/src/lib/session-attention.ts +++ b/apps/mobile/src/lib/session-attention.ts @@ -1,15 +1,30 @@ import { useSyncExternalStore } from 'react'; +import { z } from 'zod'; + +import { chainSave } from '@/lib/hooks/save-chain'; +import { SESSION_ATTENTION_KEY } from '@/lib/storage-keys'; /** - * Pure session-attention derivation + in-memory ack store for the mobile - * Agents session list "needs input" indicator. + * Durable session-attention ack store for the mobile Agents session list + * "needs input" indicator. * * Acks are written only when the user successfully answers, skips, or * responds to a permission — never on merely opening the detail screen. - * Acks are intentionally NOT persisted across app restarts. Raise identity - * is `statusUpdatedAt ?? status` (stored rows carry server + * Entries are persisted to the encrypted KV store (DEC-01) under one storage + * key and hydrated at module init, so an ack survives an app restart. No + * secrets are persisted: entries hold only session ids, raise ids, the + * attention status, and ack/expiry timestamps. + * + * Raise identity is `statusUpdatedAt ?? status` (stored rows carry server * `status_updated_at`; remote active-only rows carry none so identity - * degrades to the status string). + * degrades to the status string). Priority and action are derived, never + * stored: `question` sorts before `permission`, and the action is the + * existing navigation to the session detail. + * + * The encrypted KV is loaded lazily so the synchronous store API stays free + * of the native SQLCipher chain (and importable in node tests). Until + * hydration completes the store is empty, so badges render from server status + * alone; a restored ack then suppresses its raise. * * No backend, tRPC, or shared-package imports: this is a mobile-local * module so the web client can keep its own copy. @@ -17,15 +32,47 @@ import { useSyncExternalStore } from 'react'; const ATTENTION_STATUSES = new Set(['question', 'permission']); +/** Attention acks expire 7 days after the ack. */ +export const SESSION_ATTENTION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + +/** Item key for the single serialized entries blob under the storage key. */ +const SESSION_ATTENTION_ENTRY_KEY = 'entries'; + +const persistedEntrySchema = z.object({ + sessionId: z.string(), + raiseId: z.string().nullable(), + status: z.string().nullable(), + ackedAt: z.number().nullable(), + expiresAt: z.number().nullable(), +}); + +const persistedEntriesSchema = z.array(persistedEntrySchema); + export function sessionNeedsInput(status: string | null | undefined): boolean { return status != null && ATTENTION_STATUSES.has(status); } -type AckEntry = { raiseId: string | null }; +/** + * One durable ack entry. `sessionId` is the map key and is added to the + * persisted form at serialization time. + * + * `raiseId === null` marks a pending ack (acked, raise not yet observed). + * `ackedAt === null` marks a cleared ack (a same-session re-raise replaced + * the raise and cleared the ack so the badge returns). + */ +type AttentionEntry = { + raiseId: string | null; + status: string | null; + ackedAt: number | null; + expiresAt: number | null; +}; + +/** Serialized shape: one entry per session, `sessionId` included. */ +type PersistedAttentionEntry = AttentionEntry & { sessionId: string }; type AttentionStore = { listeners: Set<() => void>; - entries: Map; + entries: Map; revision: number; }; @@ -33,10 +80,142 @@ const STORE_KEY = '__kiloSessionAttentionStore__'; const globalScope = globalThis as typeof globalThis & { [STORE_KEY]?: AttentionStore }; const store: AttentionStore = (globalScope[STORE_KEY] ??= { listeners: new Set<() => void>(), - entries: new Map(), + entries: new Map(), revision: 0, }); +// ── Encrypted KV (lazy) ───────────────────────────────────────────────────── + +/** The two encrypted-KV calls this module uses, kept structural so the lazy + * import never pulls the native SQLCipher chain into this module's types. */ +type AttentionKv = { + getItem: (scope: string, k: string) => Promise; + setItem: (scope: string, k: string, v: string) => Promise; +}; + +let kvModulePromise: Promise | null = null; + +// eslint-disable-next-line require-await, @typescript-eslint/require-await -- single-flight must memoize the lazy import synchronously before any await; the awaits live inside the memoized import chain (same pattern as openDatabase in encrypted-kv.ts) +async function loadKv(): Promise { + kvModulePromise ??= (async () => { + try { + return await import('@/lib/persist/encrypted-kv'); + } catch { + // The native SQLCipher chain cannot load in a node test environment. + // Treat it as "KV unavailable": the in-memory store stays authoritative. + return null; + } + })(); + return kvModulePromise; +} + +// ── Persistence ───────────────────────────────────────────────────────────── + +function serializeEntries(): string { + const entries: PersistedAttentionEntry[] = []; + for (const [sessionId, entry] of store.entries) { + entries.push({ sessionId, ...entry }); + } + return JSON.stringify(entries); +} + +async function writeEntriesSafely(serialized: string): Promise { + const kv = await loadKv(); + if (!kv) { + return; + } + try { + await kv.setItem(SESSION_ATTENTION_KEY, SESSION_ATTENTION_ENTRY_KEY, serialized); + } catch { + // Swallow: a failed write keeps the in-memory store authoritative and + // retries on the next bump. + } +} + +// Writes are chained through `chainSave` so the last bump's state lands last; +// each write is fire-and-forget and never rejects. +let lastWrite: Promise | null = null; + +function persistEntries(): void { + lastWrite = chainSave(SESSION_ATTENTION_KEY, async () => { + // Serialize only after hydration settles. A write that lands during the + // hydration window must not overwrite the persisted blob before the + // hydrated entries are applied, or it erases other sessions' acks. + await hydrationPromise; + const serialized = serializeEntries(); + await writeEntriesSafely(serialized); + }); +} + +// ── Hydration ─────────────────────────────────────────────────────────────── + +function parseEntries(raw: string): PersistedAttentionEntry[] | null { + try { + const parsed: unknown = JSON.parse(raw); + const result = persistedEntriesSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } +} + +function applyHydratedEntries(raw: string): boolean { + const entries = parseEntries(raw); + if (!entries) { + return false; + } + const now = Date.now(); + const fresh = entries.filter(entry => entry.expiresAt === null || entry.expiresAt > now); + let applied = false; + for (const entry of fresh) { + // A mutation that landed after hydration began must win over the stale + // persisted entry: a present in-memory entry means this session already + // changed this run, so the persisted snapshot is out of date. + if (!store.entries.has(entry.sessionId)) { + store.entries.set(entry.sessionId, { + raiseId: entry.raiseId, + status: entry.status, + ackedAt: entry.ackedAt, + expiresAt: entry.expiresAt, + }); + applied = true; + } + } + return applied; +} + +let hydrationPromise: Promise | null = null; + +// eslint-disable-next-line require-await, @typescript-eslint/require-await -- single-flight must memoize hydration synchronously before any await; the awaits live inside the memoized hydration chain (same pattern as openDatabase in encrypted-kv.ts) +async function hydrate(): Promise { + if (hydrationPromise) { + return hydrationPromise; + } + hydrationPromise = (async () => { + const kv = await loadKv(); + if (!kv) { + return; + } + try { + const raw = await kv.getItem(SESSION_ATTENTION_KEY, SESSION_ATTENTION_ENTRY_KEY); + if (raw !== null && applyHydratedEntries(raw)) { + // Restored acks change badge decisions: notify subscribers so rows + // re-render and re-evaluate `isAttentionAcked`. + bumpRevision(); + } + } catch { + // Unreadable KV: start empty; badges re-derive from server status. + } + })(); + return hydrationPromise; +} + +// Hydrate at module init, before the first read. The store stays empty until +// this completes, so badges render from server status in the meantime. +void hydrate(); + +// ── Store ─────────────────────────────────────────────────────────────────── + function bumpRevision(): void { store.revision += 1; // Isolate subscribers: one throwing listener must not prevent the rest from @@ -50,6 +229,12 @@ function bumpRevision(): void { } } +/** Notify subscribers and persist the new entries map. */ +function commit(): void { + bumpRevision(); + persistEntries(); +} + export function subscribe(listener: () => void): () => void { store.listeners.add(listener); return () => { @@ -78,11 +263,18 @@ function getServerSnapshot(): number { * don't fire a redundant global re-render. */ export function ackSessionAttention(sessionId: string): void { - if (store.entries.get(sessionId)?.raiseId === null) { + const entry = store.entries.get(sessionId); + if (entry && entry.ackedAt !== null && entry.raiseId === null) { return; } - store.entries.set(sessionId, { raiseId: null }); - bumpRevision(); + const now = Date.now(); + store.entries.set(sessionId, { + raiseId: null, + status: null, + ackedAt: now, + expiresAt: now + SESSION_ATTENTION_EXPIRY_MS, + }); + commit(); } /** @@ -90,8 +282,11 @@ export function ackSessionAttention(sessionId: string): void { * * `raiseId = statusUpdatedAt ?? status`. * + * - expired entry: delete it and notify * - non-attention status: delete the entry (if any) and notify * - attention + existing pending entry: resolve it to the current raise + * - attention + resolved entry with a different raise: replace the raise and + * clear the ack (same-session re-raise) so the badge returns * - otherwise: no-op (does NOT bump the revision) */ export function reconcileSessionAttention( @@ -99,23 +294,68 @@ export function reconcileSessionAttention( status: string | null | undefined, statusUpdatedAt: string | null | undefined ): void { + const existing = store.entries.get(sessionId); + if (existing && existing.expiresAt !== null && existing.expiresAt <= Date.now()) { + store.entries.delete(sessionId); + commit(); + return; + } + if (!sessionNeedsInput(status)) { if (store.entries.delete(sessionId)) { - bumpRevision(); + commit(); } return; } const raiseId = statusUpdatedAt ?? status ?? null; - if (store.entries.get(sessionId)?.raiseId === null) { - store.entries.set(sessionId, { raiseId }); - bumpRevision(); + const entry = store.entries.get(sessionId); + if (!entry) { + return; + } + + if (entry.ackedAt === null) { + // Cleared ack (re-raise): keep tracking the current raise, still unacked. + if (entry.raiseId !== raiseId) { + store.entries.set(sessionId, { + raiseId, + status: status ?? null, + ackedAt: null, + expiresAt: null, + }); + commit(); + } + return; + } + + if (entry.raiseId === null) { + // Pending ack resolves to the current raise. + store.entries.set(sessionId, { + raiseId, + status: status ?? null, + ackedAt: entry.ackedAt, + expiresAt: entry.expiresAt, + }); + commit(); + return; + } + + if (entry.raiseId !== raiseId) { + // Same-session re-raise: replace the raise and clear the ack so the badge + // returns. + store.entries.set(sessionId, { + raiseId, + status: status ?? null, + ackedAt: null, + expiresAt: null, + }); + commit(); } } export function isAttentionAcked(sessionId: string, raiseId: string | null): boolean { const entry = store.entries.get(sessionId); - if (!entry) { + if (!entry || entry.ackedAt === null) { return false; } return entry.raiseId === null || entry.raiseId === raiseId; @@ -142,6 +382,8 @@ export function useSessionAttentionRevision(): number { return useSyncExternalStore(subscribe, getRevisionSnapshot, getServerSnapshot); } +// ── Test-only helpers ─────────────────────────────────────────────────────── + /** * Test-only: clear all acks and reset the revision counter so each * test starts from a known state. Not for production use. @@ -149,13 +391,37 @@ export function useSessionAttentionRevision(): number { export function __resetSessionAttentionForTests(): void { store.entries.clear(); store.revision = 0; + hydrationPromise = null; + lastWrite = null; +} + +/** Test-only: re-run hydration (a simulated restart) and return its promise. */ +export async function __hydrateSessionAttentionForTests(): Promise { + hydrationPromise = null; + await hydrate(); +} + +/** Test-only: await every queued fire-and-forget KV write. */ +export async function __flushSessionAttentionWritesForTests(): Promise { + if (lastWrite) { + await lastWrite; + } } /** - * Test-only: peek at the current entry for a session (or undefined if - * no entry exists). Lets tests assert on the raw store shape without - * exposing it on the production API. + * Test-only: peek at a session's ack state (the `raiseId` projection) or + * undefined when no entry exists. Kept as a projection for compatibility + * with existing tests; use `__peekSessionAttentionEntryForTests` for the + * full entry. */ -export function __peekSessionAttentionForTests(sessionId: string): AckEntry | undefined { +export function __peekSessionAttentionForTests( + sessionId: string +): { raiseId: string | null } | undefined { + const entry = store.entries.get(sessionId); + return entry ? { raiseId: entry.raiseId } : undefined; +} + +/** Test-only: peek at the full entry for a session (or undefined). */ +export function __peekSessionAttentionEntryForTests(sessionId: string): AttentionEntry | undefined { return store.entries.get(sessionId); } diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index cd9e729cc7..3d617048f0 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -58,6 +58,12 @@ export const PICKER_LAUNCH_CONTEXT_KEY = 'picker-launch-context'; * of the same account, matching `CONSENT_USER_KEY_PREFIX`. */ export const VOICE_NETWORK_CONSENT_KEY_PREFIX = 'voice-network-consent-'; +/** + * Encrypted-KV scope for the durable session-attention ack store (P1-F-48a). + * Holds one serialized blob of `{ sessionId, raiseId, status, ackedAt, + * expiresAt }` entries; ids and timestamps only, no secrets. + */ +export const SESSION_ATTENTION_KEY = 'session-attention'; /** * Injective hex-encoding of a per-user storage key: reversible, alphanumeric, diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx index d2670d7f87..b543faa856 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; -import type { OrganizationWithMembers } from '@/lib/organizations/organization-types'; +import type { OrganizationWithMembersResponse } from '@/lib/organizations/organization-types'; import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; import { Button } from '@/components/ui/button'; import { ConnectionStatusBadge } from './ConnectionStatusBadge'; @@ -104,7 +104,7 @@ export function McpGatewayDetailContent({ ? trpc.mcpGateway.getOrganization.queryOptions({ organizationId, configId }) : trpc.mcpGateway.getPersonal.queryOptions({ configId }) ); - const membersQuery = useQuery({ + const membersQuery = useQuery({ queryKey: organizationId ? trpc.organizations.withMembers.queryKey({ organizationId }) : [['organizations', 'withMembers', 'disabled']], diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index f2dda685ca..a9bba4969a 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -14,6 +14,10 @@ import { COUNCIL_RESULT_MARKER_TAG, COUNCIL_VERDICT_BLOCK_START, } from '@kilocode/worker-utils/code-review-council'; +import { sql } from 'drizzle-orm'; +import { db } from '@/lib/drizzle'; +import { analytics_event_outbox, operation_ledgers } from '@kilocode/db/schema'; +import { admitOperation } from '@kilocode/db/operation-ledger'; // --- Mock functions --- @@ -3904,4 +3908,96 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { expect(mockCreatePRComment).not.toHaveBeenCalled(); }); }); + + describe('code_review_settled outbox emission', () => { + beforeEach(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + afterAll(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + async function admitReview(userId = 'user-1') { + return admitOperation(db, { + userId, + domain: 'code_review', + intent: 'manual', + operationKey: `review:${REVIEW_ID}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); + } + + it('emits one code_review_settled row from the analytics completion branch', async () => { + await admitReview(); + mockGetCodeReviewById.mockResolvedValue(makeReview()); + mockGetLatestCodeReviewAttempt.mockResolvedValue( + makeAttempt({ analytics_enabled_at_dispatch: true }) + ); + + const response = await POST( + makeRequest({ status: 'completed', lastAssistantMessageText: 'Review complete.' }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_name).toBe('code_review_settled'); + expect(rows[0]?.properties).toMatchObject({ outcome: 'completed', intent: 'manual' }); + }); + + it('emits one code_review_settled row from the model-not-found terminal branch', async () => { + await admitReview(); + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + const response = await POST( + makeRequest({ status: 'failed', errorMessage: 'Model not found: kilo/retired-model' }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_name).toBe('code_review_settled'); + expect(rows[0]?.properties).toMatchObject({ outcome: 'no_op' }); + }); + + it('emits one code_review_settled row from the generic terminal branch', async () => { + await admitReview(); + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + const response = await POST(makeRequest({ status: 'completed' }), makeParams(REVIEW_ID)); + + expect(response.status).toBe(200); + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_name).toBe('code_review_settled'); + expect(rows[0]?.properties).toMatchObject({ outcome: 'completed' }); + }); + + it('settles on the terminal short-circuit when a terminal callback is redelivered', async () => { + await admitReview(); + mockGetCodeReviewById.mockResolvedValue( + makeReview({ status: 'failed', terminal_reason: 'timeout' }) + ); + + const response = await POST( + makeRequest({ status: 'failed', errorMessage: 'Execution exceeded maximum runtime' }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + message: 'Review already in terminal state', + }); + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_name).toBe('code_review_settled'); + expect(rows[0]?.properties).toMatchObject({ outcome: 'failed' }); + }); + }); }); diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts index e2ec2a47ee..100adc545b 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts @@ -30,6 +30,7 @@ import { createInfraRetryAttemptIfMissing, } from '@/lib/code-reviews/db/code-reviews'; import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews'; +import { settleCodeReviewLedgerRow } from '@/lib/code-reviews/code-review-ledger'; import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client'; import { getBotUserId } from '@/lib/bot-users/bot-user-service'; import { logExceptInTest, errorExceptInTest } from '@/lib/utils.server'; @@ -1187,6 +1188,18 @@ export async function POST( }); if (completionResult.outcome !== 'applied') { + // The completion claim did not commit here (redelivery, stale, or + // already-terminal). Settle the ledger row anyway: a transient settle + // failure on the first attempt would otherwise lose the terminal event + // forever, because redelivery short-circuits on this path. The CAS in + // settleOperation makes the retry safe, and a non-terminal review is a + // no-op. + await settleCodeReviewLedgerRow({ + reviewId, + status: review.status, + terminalReason: review.terminal_reason, + triggerSource: review.trigger_source, + }); return NextResponse.json({ success: true, message: @@ -1203,6 +1216,15 @@ export async function POST( attempt = latestAttempt; analyticsCompletionApplied = true; + // The completion claim committed, so settle the ledger row. Best-effort: + // a settle failure must not fail the callback, and a missing admit row + // skips with a log. + await settleCodeReviewLedgerRow({ + reviewId, + status: 'completed', + terminalReason: null, + triggerSource: review.trigger_source, + }); } else { attempt = await updateCodeReviewAttemptForCallback({ codeReviewId: reviewId, @@ -1250,6 +1272,16 @@ export async function POST( currentStatus: review.status, requestedStatus: status, }); + // Settle on this short-circuit path too: a transient settle failure on + // the first attempt would otherwise lose the terminal event forever, + // because redelivery lands here before any settle site. The CAS in + // settleOperation makes the retry safe. + await settleCodeReviewLedgerRow({ + reviewId, + status: review.status, + terminalReason: review.terminal_reason, + triggerSource: review.trigger_source, + }); return NextResponse.json({ success: true, message: 'Review already in terminal state', @@ -1494,8 +1526,25 @@ export async function POST( diagnostics: modelNotFoundRuntimeDiagnostics, }); } + // The terminal claim committed, so settle the ledger row once. + await settleCodeReviewLedgerRow({ + reviewId, + status, + terminalReason: terminalReason ?? null, + triggerSource: review.trigger_source, + }); } else { await updateCodeReviewStatus(reviewId, status, parentStatusUpdates); + // Settle idempotently: a cross-request redelivery of this terminal + // callback settles through the terminal short-circuit above, where the + // compare-and-set and the deterministic event uuid make the repeat a + // no-op. Non-terminal statuses (running) are a no-op. + await settleCodeReviewLedgerRow({ + reviewId, + status, + terminalReason: terminalReason ?? null, + triggerSource: review.trigger_source, + }); } let providerTerminalReason = terminalReason; diff --git a/apps/web/src/app/api/internal/security-agent/notifications/route.test.ts b/apps/web/src/app/api/internal/security-agent/notifications/route.test.ts index 8da0b58dfe..aea6097edb 100644 --- a/apps/web/src/app/api/internal/security-agent/notifications/route.test.ts +++ b/apps/web/src/app/api/internal/security-agent/notifications/route.test.ts @@ -31,6 +31,7 @@ jest.mock('@/lib/email', () => { jest.mock('@/lib/notifications-worker-client', () => ({ dispatchSecurityFindingPush: jest.fn().mockResolvedValue(undefined), + dispatchSecurityLifecyclePush: jest.fn().mockResolvedValue(undefined), dispatchLowBalancePush: jest.fn().mockResolvedValue(undefined), })); @@ -51,10 +52,14 @@ jest.mock('next/server', () => { }); import { POST } from './route'; -import { dispatchSecurityFindingPush } from '@/lib/notifications-worker-client'; +import { + dispatchSecurityFindingPush, + dispatchSecurityLifecyclePush, +} from '@/lib/notifications-worker-client'; const mockSendEmail = jest.mocked(sendEmail); const mockDispatchSecurityFindingPush = jest.mocked(dispatchSecurityFindingPush); +const mockDispatchSecurityLifecyclePush = jest.mocked(dispatchSecurityLifecyclePush); async function drainAfterCallbacks(): Promise { const mock = after as typeof after & { pending: Promise[] }; @@ -470,4 +475,115 @@ describe('POST /api/internal/security-agent/notifications', () => { ); expect(row).toBeDefined(); }); + + it('dispatches lifecycle push only: no email and no notification-row read', async () => { + const findingId = crypto.randomUUID(); + const response = await POST( + createRawRequest({ + event: 'remediation_pr_opened', + findingId, + scope: 'personal', + remediationId: crypto.randomUUID(), + prUrl: 'https://github.com/acme/api/pull/42', + recipientUserIds: ['user-a', 'user-b'], + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ outcome: 'sent' }); + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockDispatchSecurityFindingPush).not.toHaveBeenCalled(); + // Push is scheduled via after() so it does not block the route response. + expect(jest.mocked(after)).toHaveBeenCalledTimes(1); + await drainAfterCallbacks(); + expect(mockDispatchSecurityLifecyclePush).toHaveBeenCalledTimes(1); + expect(mockDispatchSecurityLifecyclePush).toHaveBeenCalledWith({ + event: 'remediation_pr_opened', + findingId, + scope: 'personal', + remediationId: expect.any(String), + prUrl: 'https://github.com/acme/api/pull/42', + recipientUserIds: ['user-a', 'user-b'], + }); + }); + + it('keeps sent response when lifecycle push dispatch rejects', async () => { + mockDispatchSecurityLifecyclePush.mockRejectedValueOnce(new Error('push worker down')); + const findingId = crypto.randomUUID(); + const response = await POST( + createRawRequest({ + event: 'analysis_completed', + findingId, + scope: 'personal', + recipientUserIds: ['user-a'], + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ outcome: 'sent' }); + expect(mockSendEmail).not.toHaveBeenCalled(); + await drainAfterCallbacks(); + expect(mockDispatchSecurityLifecyclePush).toHaveBeenCalledWith({ + event: 'analysis_completed', + findingId, + scope: 'personal', + remediationId: undefined, + prUrl: undefined, + recipientUserIds: ['user-a'], + }); + }); + + it('dispatches lifecycle push for the minimal analysis event shape', async () => { + const findingId = crypto.randomUUID(); + const response = await POST( + createRawRequest({ + event: 'analysis_failed', + findingId, + scope: 'personal', + recipientUserIds: ['user-a'], + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ outcome: 'sent' }); + await drainAfterCallbacks(); + expect(mockDispatchSecurityLifecyclePush).toHaveBeenCalledWith({ + event: 'analysis_failed', + findingId, + scope: 'personal', + remediationId: undefined, + prUrl: undefined, + recipientUserIds: ['user-a'], + }); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it('rejects lifecycle bodies with an unknown event', async () => { + const response = await POST( + createRawRequest({ + event: 'finding_created', + findingId: crypto.randomUUID(), + scope: 'personal', + recipientUserIds: ['user-a'], + }) + ); + + expect(response.status).toBe(400); + expect(mockDispatchSecurityLifecyclePush).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it('rejects lifecycle bodies with an empty recipient list', async () => { + const response = await POST( + createRawRequest({ + event: 'analysis_completed', + findingId: crypto.randomUUID(), + scope: 'personal', + recipientUserIds: [], + }) + ); + + expect(response.status).toBe(400); + expect(mockDispatchSecurityLifecyclePush).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/api/internal/security-agent/notifications/route.ts b/apps/web/src/app/api/internal/security-agent/notifications/route.ts index e7e4407e8b..d335b025b9 100644 --- a/apps/web/src/app/api/internal/security-agent/notifications/route.ts +++ b/apps/web/src/app/api/internal/security-agent/notifications/route.ts @@ -13,8 +13,12 @@ import type { SecurityFindingNotificationKind } from '@kilocode/db/schema-types' import { db } from '@/lib/drizzle'; import { INTERNAL_API_SECRET, NEXTAUTH_URL } from '@/lib/config.server'; import { send as sendEmail, type TemplateName } from '@/lib/email'; -import { dispatchSecurityFindingPush } from '@/lib/notifications-worker-client'; +import { + dispatchSecurityFindingPush, + dispatchSecurityLifecyclePush, +} from '@/lib/notifications-worker-client'; import { securityFindingTemplateVars } from '@/lib/security-notification-email-vars'; +import { securityLifecycleEventSchema } from '@kilocode/notifications'; import { SecurityNotificationPolicySchema, getEligibleSlaNotificationKind, @@ -23,12 +27,28 @@ import { const SECRET_COMPARE_HMAC_KEY = Buffer.from('security-agent-notification-secret-compare'); -const BodySchema = z +// The sweep posts the strict `{ notificationId }` shape with no discriminator, +// so a discriminated union on `kind` would break it. A plain union keeps the +// old shape parsing while adding the lifecycle shape. +const NotificationBodySchema = z .object({ notificationId: z.string().uuid(), }) .strict(); +const SecurityLifecycleBodySchema = z + .object({ + event: securityLifecycleEventSchema, + findingId: z.string().uuid(), + scope: z.string().min(1), + remediationId: z.string().uuid().optional(), + prUrl: z.string().url().optional(), + recipientUserIds: z.array(z.string().min(1)).min(1), + }) + .strict(); + +const BodySchema = z.union([NotificationBodySchema, SecurityLifecycleBodySchema]); + const notificationKindToTemplate = { new_finding: 'securityFindingNew', sla_warning: 'securityFindingSlaWarning', @@ -186,6 +206,30 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); } + // Lifecycle events have no persisted `security_finding_notifications` row, + // so they bypass the notification-row lookup and the email template pipeline + // entirely and dispatch push only. + if (!('notificationId' in parsedBody.data)) { + const body = parsedBody.data; + // Push must not extend route latency: the security-auto-analysis producer + // aborts at 10s and would drop the push if the route held the response. + after(async () => { + try { + await dispatchSecurityLifecyclePush({ + event: body.event, + findingId: body.findingId, + scope: body.scope, + remediationId: body.remediationId, + prUrl: body.prUrl, + recipientUserIds: body.recipientUserIds, + }); + } catch { + // Push is best-effort: the lifecycle persist already committed. + } + }); + return NextResponse.json({ outcome: 'sent' }, { status: 200 }); + } + const [row] = await db .select({ notificationId: security_finding_notifications.id, diff --git a/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.test.ts b/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.test.ts index 1abb767c41..df63b3050f 100644 --- a/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.test.ts +++ b/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.test.ts @@ -4,6 +4,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from const mockFetchBitbucketPullRequest = jest.fn(); const mockTryDispatchPendingReviews = jest.fn(); const mockCancelReview = jest.fn(); +const mockSettleOperation = jest.fn(); +const mockGetBitbucketCodeReviewerReadiness = jest.fn(); const mockBitbucketSigningKeys = JSON.stringify({ active: Buffer.alloc(32, 31).toString('base64'), previous: Buffer.alloc(32, 47).toString('base64'), @@ -35,12 +37,26 @@ jest.mock('@/lib/code-reviews/client/code-review-worker-client', () => ({ }, })); +jest.mock('@kilocode/db/operation-ledger', () => { + const actual = jest.requireActual('@kilocode/db/operation-ledger'); + return { + ...actual, + settleOperation: (...args: unknown[]) => mockSettleOperation(...args), + }; +}); + +jest.mock('@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketCodeReviewerReadiness: (...args: unknown[]) => + mockGetBitbucketCodeReviewerReadiness(...args), +})); + import { createHmac, randomUUID } from 'node:crypto'; import { NextRequest } from 'next/server'; import { agent_configs, cloud_agent_code_reviews, kilocode_users, + operation_ledgers, organization_memberships, organizations, platform_integrations, @@ -49,7 +65,7 @@ import { type PlatformIntegration, type User, } from '@kilocode/db/schema'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/drizzle'; import { generateBotUserId } from '@/lib/bot-users/types'; import { @@ -62,6 +78,7 @@ import { deriveBitbucketWebhookSecret, parseBitbucketWebhookSigningKeyring, } from '@/lib/integrations/platforms/bitbucket/webhook-signing'; +import { triggerManualBitbucketCodeReview } from '@/lib/integrations/platforms/bitbucket/manual-code-review-trigger'; import { POST } from './route'; const WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'; @@ -274,6 +291,30 @@ async function organizationWebhookEvents() { .where(eq(webhook_events.owned_by_organization_id, organization.id)); } +function readinessFor(integration: PlatformIntegration) { + return { + connected: true, + ready: true, + integrationId: integration.id, + workspace: { uuid: WORKSPACE_UUID, slug: 'acme', displayName: 'Acme Workspace' }, + missingRequiredScopes: [], + repositoryCache: { + status: 'available' as const, + repositories: [ + { + id: REPOSITORY_UUID, + workspaceUuid: WORKSPACE_UUID, + name: 'widgets', + fullName: REPOSITORY_FULL_NAME, + private: true, + defaultBranch: 'main', + }, + ], + syncedAt: '2026-06-24T08:00:00.000Z', + }, + }; +} + describe('POST /api/webhooks/bitbucket/[integrationId]', () => { beforeAll(async () => { ownerUser = await insertTestUser(); @@ -291,6 +332,10 @@ describe('POST /api/webhooks/bitbucket/[integrationId]', () => { beforeEach(() => { jest.clearAllMocks(); + const actualOperationLedger = jest.requireActual('@kilocode/db/operation-ledger') as { + settleOperation: (...args: unknown[]) => Promise; + }; + mockSettleOperation.mockImplementation(actualOperationLedger.settleOperation); mockFetchBitbucketPullRequest.mockResolvedValue(providerPullRequest()); mockTryDispatchPendingReviews.mockResolvedValue({ dispatched: 1, @@ -301,6 +346,18 @@ describe('POST /api/webhooks/bitbucket/[integrationId]', () => { }); afterEach(async () => { + const reviewIds = await db + .select({ id: cloud_agent_code_reviews.id }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.owned_by_organization_id, organization.id)); + if (reviewIds.length > 0) { + await db.delete(operation_ledgers).where( + inArray( + operation_ledgers.operation_key, + reviewIds.map(review => `review:${review.id}`) + ) + ); + } await db .delete(webhook_events) .where(eq(webhook_events.owned_by_organization_id, organization.id)); @@ -646,4 +703,119 @@ describe('POST /api/webhooks/bitbucket/[integrationId]', () => { expect(mockCancelReview).toHaveBeenCalledWith(reviewId, expect.any(String), undefined); expect(mockTryDispatchPendingReviews).not.toHaveBeenCalled(); }); + + it('admits the code_review ledger row after the review transaction commits', async () => { + const integration = await insertIntegrationAndConfig(); + + const response = await callWebhook(integration, webhookRequest(integration)); + + expect(response.status).toBe(202); + const reviews = await organizationReviews(); + expect(reviews).toHaveLength(1); + const reviewId = reviews[0].id; + + const [ledgerRow] = await db + .select({ + domain: operation_ledgers.domain, + operationKey: operation_ledgers.operation_key, + intent: operation_ledgers.intent, + }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + + expect(ledgerRow).toEqual({ + domain: 'code_review', + operationKey: `review:${reviewId}`, + intent: 'webhook', + }); + }); + + it('settles the admitted ledger row as superseded after a superseding webhook cancels active work', async () => { + const integration = await insertIntegrationAndConfig(); + const reviewId = await createExistingReview(integration, DEFAULT_HEAD_SHA, 'queued'); + mockFetchBitbucketPullRequest.mockResolvedValueOnce( + providerPullRequest({ state: 'MERGED', draft: false }) + ); + + const response = await callWebhook( + integration, + webhookRequest(integration, { eventKey: 'pullrequest:fulfilled' }) + ); + + expect(response.status).toBe(200); + const [ledgerRow] = await db + .select({ status: operation_ledgers.status }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + expect(ledgerRow?.status).toBe('superseded'); + }); + + it('still succeeds when the superseded ledger settle fails', async () => { + const integration = await insertIntegrationAndConfig(); + const reviewId = await createExistingReview(integration, DEFAULT_HEAD_SHA, 'queued'); + mockFetchBitbucketPullRequest.mockResolvedValueOnce( + providerPullRequest({ state: 'MERGED', draft: false }) + ); + mockSettleOperation.mockRejectedValueOnce(new Error('ledger unavailable')); + + const response = await callWebhook( + integration, + webhookRequest(integration, { eventKey: 'pullrequest:fulfilled' }) + ); + + expect(response.status).toBe(200); + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + expect((await organizationReviews())[0]).toEqual( + expect.objectContaining({ id: reviewId, status: 'cancelled', terminal_reason: 'superseded' }) + ); + }); + + it('settles the admitted ledger row as superseded after a superseding manual trigger', async () => { + const integration = await insertIntegrationAndConfig(); + const reviewId = await createExistingReview(integration, DEFAULT_HEAD_SHA, 'queued'); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue(readinessFor(integration)); + mockFetchBitbucketPullRequest.mockResolvedValueOnce( + providerPullRequest({ headSha: 'b'.repeat(40) }) + ); + + const result = await triggerManualBitbucketCodeReview({ + organizationId: organization.id, + pullRequestUrl: `https://bitbucket.org/${REPOSITORY_FULL_NAME}/pull-requests/${PULL_REQUEST_ID}`, + }); + + expect(result.status).toBe('queued'); + const [cancelledReview] = await db + .select({ status: cloud_agent_code_reviews.status }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.id, reviewId)); + expect(cancelledReview?.status).toBe('cancelled'); + const [ledgerRow] = await db + .select({ status: operation_ledgers.status }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + expect(ledgerRow?.status).toBe('superseded'); + }); + + it('still starts the review when the superseded ledger settle fails for a manual trigger', async () => { + const integration = await insertIntegrationAndConfig(); + const reviewId = await createExistingReview(integration, DEFAULT_HEAD_SHA, 'queued'); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue(readinessFor(integration)); + mockFetchBitbucketPullRequest.mockResolvedValueOnce( + providerPullRequest({ headSha: 'b'.repeat(40) }) + ); + mockSettleOperation.mockRejectedValueOnce(new Error('ledger unavailable')); + + const result = await triggerManualBitbucketCodeReview({ + organizationId: organization.id, + pullRequestUrl: `https://bitbucket.org/${REPOSITORY_FULL_NAME}/pull-requests/${PULL_REQUEST_ID}`, + }); + + expect(result.status).toBe('queued'); + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + const [cancelledReview] = await db + .select({ status: cloud_agent_code_reviews.status }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.id, reviewId)); + expect(cancelledReview?.status).toBe('cancelled'); + }); }); diff --git a/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.ts b/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.ts index 12a41cb570..f475dc19eb 100644 --- a/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.ts +++ b/apps/web/src/app/api/webhooks/bitbucket/[integrationId]/route.ts @@ -8,6 +8,7 @@ import { db, type DrizzleTransaction } from '@/lib/drizzle'; import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; import { getUnblockedBotUserForOrg } from '@/lib/bot-users/bot-user-service'; import { + admitCodeReviewLedgerRow, bitbucketCodeReviewerLifecycleLockKey, cancelActiveReviewsForPRInTransaction, cancelSupersededReviewsForPRInTransaction, @@ -17,6 +18,7 @@ import { type ReviewScope, } from '@/lib/code-reviews/db/code-reviews'; import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client'; +import { settleCodeReviewLedgerRow } from '@/lib/code-reviews/code-review-ledger'; import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews'; import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; import { fetchBitbucketPullRequestFromTokenService } from '@/lib/integrations/platforms/bitbucket/token-service-client'; @@ -250,6 +252,17 @@ async function interruptCancelledReviews(cancelledReviews: CancelledReviewRow[]) ); } +async function settleCancelledReviews(cancelledReviews: CancelledReviewRow[]): Promise { + for (const review of cancelledReviews) { + await settleCodeReviewLedgerRow({ + reviewId: review.id, + status: 'cancelled', + terminalReason: 'superseded', + triggerSource: review.triggerSource, + }); + } +} + function isOlderObservation(observed: string, greatestProcessed: string | null): boolean { if (!greatestProcessed) return false; return new Date(observed).getTime() < new Date(greatestProcessed).getTime(); @@ -545,7 +558,18 @@ export async function POST(request: NextRequest, context: RouteContext) { } await interruptCancelledReviews(transactionResult.cancelledReviews); + await settleCancelledReviews(transactionResult.cancelledReviews); if (transactionResult.created) { + if (transactionResult.reviewId) { + // Best-effort ledger admission (P1-A-07c): the review row already + // committed, so a ledger write failure must not fail the webhook. + await admitCodeReviewLedgerRow({ + reviewId: transactionResult.reviewId, + userId: ownerWithBot.userId, + orgId: ownerWithBot.type === 'org' ? ownerWithBot.id : null, + triggerSource: 'webhook', + }); + } try { await tryDispatchPendingReviews(ownerWithBot); } catch { diff --git a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx index e57d637be5..182d03e1b9 100644 --- a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx @@ -276,6 +276,8 @@ export function GitLabIntegrationDetails({ missing_code: 'Authorization code missing from GitLab', connection_failed: 'Failed to connect to GitLab', oauth_init_failed: 'Failed to initiate GitLab OAuth', + permission_required: 'You need a billing role to replace this GitLab integration', + organization_access_required: 'You do not have access to this organization', }; toast.error(errorMessages[error] || `Connection failed: ${error}`); } diff --git a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx index 556c05c574..601d9daa79 100644 --- a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx +++ b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx @@ -8,13 +8,13 @@ import { getOrgTrialStatusFromDays } from '@/lib/organizations/trial-utils'; import type { OrgTrialStatus, OrganizationRole, - OrganizationWithMembers, + OrganizationWithMembersResponse, } from '@/lib/organizations/organization-types'; import { capitalize, cn } from '@/lib/utils'; import { canManageOrganization } from '@kilocode/app-shared/organizations'; type FreeTrialWarningBannerProps = { - organization: OrganizationWithMembers; + organization: OrganizationWithMembersResponse; daysRemaining: number; userRole: OrganizationRole; onUpgradeClick: () => void; diff --git a/apps/web/src/components/organizations/FreeTrialWarningDialog.tsx b/apps/web/src/components/organizations/FreeTrialWarningDialog.tsx index 42efe8d3e8..9bf2c91ff7 100644 --- a/apps/web/src/components/organizations/FreeTrialWarningDialog.tsx +++ b/apps/web/src/components/organizations/FreeTrialWarningDialog.tsx @@ -12,13 +12,13 @@ import { Button } from '@/components/Button'; import { Lock } from 'lucide-react'; import type { OrgTrialStatus, - OrganizationWithMembers, + OrganizationWithMembersResponse, } from '@/lib/organizations/organization-types'; type FreeTrialWarningDialogProps = { trialStatus: OrgTrialStatus; daysExpired: number; - organization: OrganizationWithMembers; + organization: OrganizationWithMembersResponse; onClose?: () => void; onUpgradeClick: () => void; container?: HTMLElement | null; diff --git a/apps/web/src/components/organizations/OrganizationContextWrapper.tsx b/apps/web/src/components/organizations/OrganizationContextWrapper.tsx index d043fe30e1..6e2069bf52 100644 --- a/apps/web/src/components/organizations/OrganizationContextWrapper.tsx +++ b/apps/web/src/components/organizations/OrganizationContextWrapper.tsx @@ -5,7 +5,7 @@ import { OrganizationContextProvider } from './OrganizationContext'; import { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; import { useRoleTesting } from '@/contexts/RoleTestingContext'; import { useSession } from 'next-auth/react'; -import type { OrganizationMember } from '@/lib/organizations/organization-types'; +import type { OrganizationMemberResponse } from '@/lib/organizations/organization-types'; type OrganizationContextWrapperProps = { organizationId: string; @@ -24,7 +24,7 @@ export function OrganizationAdminContextProvider({ // Get current organization role const actualRole = organizationData?.members?.find( - (member: OrganizationMember) => + (member: OrganizationMemberResponse) => member.email === session?.data?.user?.email && member.status === 'active' )?.role; diff --git a/apps/web/src/components/organizations/OrganizationInfoCard.tsx b/apps/web/src/components/organizations/OrganizationInfoCard.tsx index 102f8ba35e..eeadcd9394 100644 --- a/apps/web/src/components/organizations/OrganizationInfoCard.tsx +++ b/apps/web/src/components/organizations/OrganizationInfoCard.tsx @@ -10,7 +10,7 @@ import { useAdminToggleCodeIndexing, useUpdateSuppressTrialMessaging, } from '@/app/api/organizations/hooks'; -import type { OrganizationWithMembers } from '@/lib/organizations/organization-types'; +import type { OrganizationWithMembersResponse } from '@/lib/organizations/organization-types'; import { normalizeCompanyDomain, isValidDomain } from '@/lib/organizations/company-domain'; import { ErrorCard } from '@/components/ErrorCard'; import { LoadingCard } from '@/components/LoadingCard'; @@ -69,7 +69,7 @@ function useCanManagePaymentInfo() { } type InnerProps = { - info: OrganizationWithMembers; + info: OrganizationWithMembersResponse; className?: string; showAdminControls: boolean; }; @@ -156,10 +156,12 @@ function Inner(props: InnerProps) { updated_at, total_microdollars_acquired, microdollars_used, - stripe_customer_id, deleted_at, auto_top_up_enabled, } = info; + // The member variant omits the Stripe customer id, so read it only on the + // admin-and-above variant. The field is rendered only in the admin dashboard. + const stripe_customer_id = info.callerRole === 'member' ? null : info.stripe_customer_id; const [isEditing, setIsEditing] = useState(false); const [editedName, setEditedName] = useState(name); diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 507fbdc831..e0c8ff5430 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -33,8 +33,8 @@ import { ErrorCard } from '../ErrorCard'; import { LoadingCard } from '../LoadingCard'; import type { OrganizationRole, - OrganizationMember, - OrganizationWithMembers, + OrganizationMemberResponse, + OrganizationWithMembersResponse, } from '@/lib/organizations/organization-types'; import { useIsKiloAdmin, @@ -60,7 +60,7 @@ const formatDate = (dateString: string) => { }; type DailyUsageLimitDisplayProps = { - member: OrganizationMember; + member: OrganizationMemberResponse; }; function DailyUsageLimitDisplay({ member }: DailyUsageLimitDisplayProps) { @@ -114,7 +114,7 @@ const canRemoveMember = ( type DeleteMemberButtonProps = { organizationId: string; - member: OrganizationMember; + member: OrganizationMemberResponse; }; function DeleteMemberButton({ organizationId, member }: DeleteMemberButtonProps) { @@ -155,7 +155,7 @@ function DeleteMemberButton({ organizationId, member }: DeleteMemberButtonProps) type DeleteInvitationButtonProps = { organizationId: string; - member: OrganizationMember; + member: OrganizationMemberResponse; }; function DeleteInvitationButton({ organizationId, member }: DeleteInvitationButtonProps) { @@ -197,7 +197,7 @@ function DeleteInvitationButton({ organizationId, member }: DeleteInvitationButt } type InvitedBadgeProps = { - member: OrganizationMember; + member: OrganizationMemberResponse; }; function InvitedBadge({ member }: InvitedBadgeProps) { @@ -216,12 +216,18 @@ function InvitedBadge({ member }: InvitedBadgeProps) { const isFailed = member.emailStatus === 'failed'; const variant = isFailed ? 'destructive' : 'secondary'; - const canCopy = canManageMembers(currentUserRole, isKiloAdmin); + const canCopy = canManageMembers(currentUserRole, isKiloAdmin) && 'inviteUrl' in member; const handleCopy = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + // The member variant strips the invite URL, so the copy action is only + // available on the admin-and-above variant that still carries it. + if (!('inviteUrl' in member)) { + return; + } + try { await navigator.clipboard.writeText(member.inviteUrl); toast.success('Invite URL copied to clipboard'); @@ -250,7 +256,7 @@ function InvitedBadge({ member }: InvitedBadgeProps) { type ResendInvitationButtonProps = { organizationId: string; - member: OrganizationMember; + member: OrganizationMemberResponse; }; function ResendInvitationButton({ organizationId, member }: ResendInvitationButtonProps) { @@ -305,8 +311,8 @@ function ResendInvitationButton({ organizationId, member }: ResendInvitationButt } type EditLimitButtonProps = { - organization: OrganizationWithMembers; - member: OrganizationMember; + organization: OrganizationWithMembersResponse; + member: OrganizationMemberResponse; }; function EditLimitButton({ organization, member }: EditLimitButtonProps) { @@ -364,8 +370,8 @@ function EditLimitButton({ organization, member }: EditLimitButtonProps) { } type ChildTeamsControlProps = { - organization: OrganizationWithMembers; - member: OrganizationMember; + organization: OrganizationWithMembersResponse; + member: OrganizationMemberResponse; editable: boolean; canOpenChildOrganizations: boolean; }; diff --git a/apps/web/src/components/organizations/SSOSignupCard.tsx b/apps/web/src/components/organizations/SSOSignupCard.tsx index 47db117ccb..f16211ec63 100644 --- a/apps/web/src/components/organizations/SSOSignupCard.tsx +++ b/apps/web/src/components/organizations/SSOSignupCard.tsx @@ -8,12 +8,12 @@ import { toast } from 'sonner'; import { usePostHog } from 'posthog-js/react'; import type { OrganizationRole, - OrganizationWithMembers, + OrganizationWithMembersResponse, } from '@/lib/organizations/organization-types'; import { canManageOrganization } from '@kilocode/app-shared/organizations'; type SSOSignupCardProps = { - organization: OrganizationWithMembers; + organization: OrganizationWithMembersResponse; role: OrganizationRole; }; diff --git a/apps/web/src/components/organizations/groups/drawer/GroupDetailsPanel.tsx b/apps/web/src/components/organizations/groups/drawer/GroupDetailsPanel.tsx index 4a36ad07c3..e5edd923f0 100644 --- a/apps/web/src/components/organizations/groups/drawer/GroupDetailsPanel.tsx +++ b/apps/web/src/components/organizations/groups/drawer/GroupDetailsPanel.tsx @@ -126,7 +126,9 @@ export function GroupDetailsPanel({ const group = entry.mode === 'edit' ? groupQuery.data?.group : undefined; const members = - organizationQuery.data?.members.filter(member => member.status === 'active') ?? []; + organizationQuery.data?.members.flatMap(member => + member.status === 'active' ? [member] : [] + ) ?? []; const visibleMembers = members.filter(member => `${member.name} ${member.email}`.toLowerCase().includes(memberSearch.toLowerCase()) ); diff --git a/apps/web/src/components/organizations/members/EditDailyUsageLimitUsdDialog.tsx b/apps/web/src/components/organizations/members/EditDailyUsageLimitUsdDialog.tsx index 2ddc6cd92a..da1538000d 100644 --- a/apps/web/src/components/organizations/members/EditDailyUsageLimitUsdDialog.tsx +++ b/apps/web/src/components/organizations/members/EditDailyUsageLimitUsdDialog.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import type { OrganizationMember } from '@/lib/organizations/organization-types'; +import type { OrganizationMemberResponse } from '@/lib/organizations/organization-types'; import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { useUpdateDailyUsageLimitUsd } from '@/app/api/organizations/hooks'; @@ -24,7 +24,7 @@ type EditDailyUsageLimitUsdDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; organizationId: string; - member: OrganizationMember; + member: OrganizationMemberResponse; onLimitUpdated: () => void; }; diff --git a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx index 1f12ec1269..53f208e670 100644 --- a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx +++ b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx @@ -10,7 +10,10 @@ import { } from '@/components/ui/dropdown-menu'; import { ChevronDown } from 'lucide-react'; import { useUpdateMemberRole } from '@/app/api/organizations/hooks'; -import type { OrganizationRole, OrganizationMember } from '@/lib/organizations/organization-types'; +import type { + OrganizationRole, + OrganizationMemberResponse, +} from '@/lib/organizations/organization-types'; import { useIsKiloAdmin, useUserOrganizationRole, @@ -58,7 +61,7 @@ const getAvailableRoles = ( type MemberRoleDropdownProps = { organizationId: string; - member: OrganizationMember; + member: OrganizationMemberResponse; showAsReadOnly?: boolean; }; diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 95784a3e82..5a77f80c69 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -12,7 +12,10 @@ import { import { toast } from 'sonner'; import type { SecurityFinding } from '@kilocode/db/schema'; import type { SecurityRemediationAdmissionRejectionReason } from '@kilocode/worker-utils/security-remediation-policy'; -import { getSecurityCommandFailureMessage } from '@kilocode/app-shared/security-agent'; +import { + getSecurityCommandFailureMessage, + type SecurityCommandType, +} from '@kilocode/app-shared/security-agent'; import type { SecurityAgentUiInteraction } from '@/lib/security-agent/core/schemas'; import type { DependabotAlertsAvailability } from '@/lib/security-agent/core/types'; import { isGitHubIntegrationError } from '@/lib/security-agent/core/error-display'; @@ -65,6 +68,7 @@ type SecurityAgentContextValue = { autoRemediationEnabled: boolean; autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoRemediationIncludeExisting: boolean; + autoRemediationRequireApproval: boolean; autoRemediationEnabledAt: string | null; remediationModelSlug?: string; slaNotificationsEnabled: boolean; @@ -118,6 +122,7 @@ type SecurityAgentContextValue = { autoRemediationEnabled: boolean; autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoRemediationIncludeExisting: boolean; + autoRemediationRequireApproval: boolean; remediationModelSlug: string; slaNotificationsEnabled: boolean; slaNotificationMinSeverity: 'critical' | 'high' | 'medium' | 'low'; @@ -203,7 +208,7 @@ const EMPTY_ORPHANED_REPOSITORIES: SecurityAgentContextValue['orphanedRepositori export type SecurityAgentCommand = { id: string; - commandType: 'sync' | 'dismiss_finding' | 'start_analysis' | 'apply_auto_remediation'; + commandType: SecurityCommandType; findingId: string | null; status: 'accepted' | 'running' | 'succeeded' | 'failed' | 'no_op'; resultCode: string | null; @@ -1188,6 +1193,7 @@ function useSecurityAgentProviderValue( autoRemediationEnabled: boolean; autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoRemediationIncludeExisting: boolean; + autoRemediationRequireApproval: boolean; remediationModelSlug: string; slaNotificationsEnabled: boolean; slaNotificationMinSeverity: 'critical' | 'high' | 'medium' | 'low'; @@ -1225,6 +1231,7 @@ function useSecurityAgentProviderValue( autoRemediationEnabled: config.autoRemediationEnabled, autoRemediationMinSeverity: config.autoRemediationMinSeverity, autoRemediationIncludeExisting: config.autoRemediationIncludeExisting, + autoRemediationRequireApproval: config.autoRemediationRequireApproval, slaNotificationsEnabled: config.slaNotificationsEnabled, slaNotificationMinSeverity: config.slaNotificationMinSeverity, slaNotificationWarningDays: config.slaNotificationWarningDays, @@ -1254,6 +1261,7 @@ function useSecurityAgentProviderValue( autoRemediationEnabled: config.autoRemediationEnabled, autoRemediationMinSeverity: config.autoRemediationMinSeverity, autoRemediationIncludeExisting: config.autoRemediationIncludeExisting, + autoRemediationRequireApproval: config.autoRemediationRequireApproval, slaNotificationsEnabled: config.slaNotificationsEnabled, slaNotificationMinSeverity: config.slaNotificationMinSeverity, slaNotificationWarningDays: config.slaNotificationWarningDays, @@ -1421,6 +1429,7 @@ function useSecurityAgentProviderValue( autoRemediationEnabled: configData.autoRemediationEnabled ?? false, autoRemediationMinSeverity: configData.autoRemediationMinSeverity ?? 'high', autoRemediationIncludeExisting: configData.autoRemediationIncludeExisting ?? false, + autoRemediationRequireApproval: configData.autoRemediationRequireApproval ?? true, autoRemediationEnabledAt: configData.autoRemediationEnabledAt ?? null, remediationModelSlug, } diff --git a/apps/web/src/components/security-agent/SecurityConfigForm.test.ts b/apps/web/src/components/security-agent/SecurityConfigForm.test.ts new file mode 100644 index 0000000000..6dbac82dbc --- /dev/null +++ b/apps/web/src/components/security-agent/SecurityConfigForm.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from '@jest/globals'; +import { + buildSecurityConfigFormState, + buildSecurityConfigSavePayload, + type SecurityConfigFormState, +} from './security-config-types'; + +const baseFormState: SecurityConfigFormState = { + slaConfig: { critical: 15, high: 30, medium: 45, low: 90 }, + slaEnabled: true, + repositorySelectionMode: 'selected', + selectedRepositoryIds: [], + triageModelSlug: 'triage-model', + analysisModelSlug: 'analysis-model', + analysisMode: 'auto', + autoDismissEnabled: false, + autoDismissConfidenceThreshold: 'high', + autoAnalysisEnabled: false, + autoAnalysisMinSeverity: 'high', + autoAnalysisIncludeExisting: false, + autoRemediationEnabled: true, + autoRemediationMinSeverity: 'high', + autoRemediationIncludeExisting: false, + autoRemediationRequireApproval: false, + remediationModelSlug: 'remediation-model', + slaNotificationsEnabled: false, + slaNotificationMinSeverity: 'high', + slaNotificationWarningDays: 3, + newFindingNotificationsEnabled: false, + newFindingNotificationMinSeverity: 'high', +}; + +describe('SecurityConfigForm config round-trip', () => { + it('includes autoRemediationRequireApproval in the save payload', () => { + const payload = buildSecurityConfigSavePayload(baseFormState); + + expect(payload.autoRemediationRequireApproval).toBe(false); + }); + + it('preserves a hydrated autoRemediationRequireApproval=false value', () => { + const formState = buildSecurityConfigFormState({ autoRemediationRequireApproval: false }); + + expect(formState.autoRemediationRequireApproval).toBe(false); + expect(buildSecurityConfigSavePayload(formState).autoRemediationRequireApproval).toBe(false); + }); + + it('defaults autoRemediationRequireApproval to true when the config omits it', () => { + const formState = buildSecurityConfigFormState(undefined); + + expect(formState.autoRemediationRequireApproval).toBe(true); + }); +}); diff --git a/apps/web/src/components/security-agent/SecurityConfigForm.tsx b/apps/web/src/components/security-agent/SecurityConfigForm.tsx index 9911f85d35..0cb58f8934 100644 --- a/apps/web/src/components/security-agent/SecurityConfigForm.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigForm.tsx @@ -2,7 +2,16 @@ import { type SetStateAction, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Bell, Bot, Clock, Loader2, RotateCcw, Save, SlidersHorizontal } from 'lucide-react'; +import { + Bell, + Bot, + Clock, + GitPullRequest, + Loader2, + RotateCcw, + Save, + SlidersHorizontal, +} from 'lucide-react'; import { useOrganizationModels } from '@/components/cloud-agent/hooks/useOrganizationModels'; import type { ModelOption } from '@/components/shared/ModelCombobox'; import { @@ -16,6 +25,9 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; import type { SecurityAgentUiInteraction } from '@/lib/security-agent/core/schemas'; @@ -42,6 +54,7 @@ import type { SecurityRepository, SlaConfig, } from './security-config-types'; +import { buildSecurityConfigSavePayload } from './security-config-types'; import { useSecurityAgent } from './SecurityAgentContext'; import { SecurityAgentActionBar } from './SecurityAgentActionBar'; @@ -99,6 +112,7 @@ const DEFAULT_FORM_CONFIG: SecurityConfigFormState = { autoRemediationEnabled: false, autoRemediationMinSeverity: 'high', autoRemediationIncludeExisting: false, + autoRemediationRequireApproval: true, remediationModelSlug: DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, ...DEFAULT_NOTIFICATION_CONFIG, }; @@ -127,6 +141,7 @@ function configFingerprint(config: SecurityConfigFormState) { config.autoRemediationEnabled, config.autoRemediationMinSeverity, config.autoRemediationIncludeExisting, + config.autoRemediationRequireApproval, config.remediationModelSlug, config.slaNotificationsEnabled, config.slaNotificationMinSeverity, @@ -253,33 +268,7 @@ export function SecurityConfigForm({ const handleSave = (options?: { onSuccess?: () => void; onError?: () => void }) => { if (saveDisabled) return; - onSave( - { - ...state.slaConfig, - slaEnabled: state.slaEnabled, - repositorySelectionMode: state.repositorySelectionMode, - selectedRepositoryIds: state.selectedRepositoryIds, - triageModelSlug: state.triageModelSlug, - analysisModelSlug: state.analysisModelSlug, - modelSlug: state.analysisModelSlug, - analysisMode: state.analysisMode, - autoDismissEnabled: state.autoDismissEnabled, - autoDismissConfidenceThreshold: state.autoDismissConfidenceThreshold, - autoAnalysisEnabled: state.autoAnalysisEnabled, - autoAnalysisMinSeverity: state.autoAnalysisMinSeverity, - autoAnalysisIncludeExisting: state.autoAnalysisIncludeExisting, - autoRemediationEnabled: state.autoRemediationEnabled, - autoRemediationMinSeverity: state.autoRemediationMinSeverity, - autoRemediationIncludeExisting: state.autoRemediationIncludeExisting, - remediationModelSlug: state.remediationModelSlug, - slaNotificationsEnabled: state.slaNotificationsEnabled, - slaNotificationMinSeverity: state.slaNotificationMinSeverity, - slaNotificationWarningDays: state.slaNotificationWarningDays, - newFindingNotificationsEnabled: state.newFindingNotificationsEnabled, - newFindingNotificationMinSeverity: state.newFindingNotificationMinSeverity, - }, - options - ); + onSave(buildSecurityConfigSavePayload(state), options); }; const clearPendingNavigation = () => { @@ -478,6 +467,49 @@ export function SecurityConfigForm({ + {state.autoRemediationEnabled && ( + + +
+
+
+
+ + Auto-remediation approval + +

+ Require approval before opening remediation PRs. +

+
+
+
+ +
+
+ +

+ Auto-remediation waits for your approval before opening PRs. +

+
+ + setState(current => ({ ...current, autoRemediationRequireApproval })) + } + aria-describedby="auto-remediation-require-approval-description" + className="shrink-0 self-end sm:self-auto" + /> +
+
+
+ )}
diff --git a/apps/web/src/components/security-agent/SecurityConfigPage.tsx b/apps/web/src/components/security-agent/SecurityConfigPage.tsx index f88489265d..be61a2c83d 100644 --- a/apps/web/src/components/security-agent/SecurityConfigPage.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigPage.tsx @@ -73,6 +73,7 @@ export function SecurityConfigPage() { autoRemediationEnabled: configData?.autoRemediationEnabled ?? false, autoRemediationMinSeverity: configData?.autoRemediationMinSeverity ?? 'high', autoRemediationIncludeExisting: configData?.autoRemediationIncludeExisting ?? false, + autoRemediationRequireApproval: configData?.autoRemediationRequireApproval ?? true, remediationModelSlug: configData?.remediationModelSlug ?? configData?.analysisModelSlug ?? diff --git a/apps/web/src/components/security-agent/security-agent-command-copy.ts b/apps/web/src/components/security-agent/security-agent-command-copy.ts index 6e106a4ac0..65d9d6a762 100644 --- a/apps/web/src/components/security-agent/security-agent-command-copy.ts +++ b/apps/web/src/components/security-agent/security-agent-command-copy.ts @@ -1,8 +1,7 @@ +import type { SecurityCommandType } from '@kilocode/app-shared/security-agent'; + export type SecurityAgentAdmissionAction = - | 'sync' - | 'dismiss_finding' - | 'start_analysis' - | 'apply_auto_remediation' + | SecurityCommandType | 'enable_initial_sync' | 'existing_findings_backlog'; diff --git a/apps/web/src/components/security-agent/security-config-types.ts b/apps/web/src/components/security-agent/security-config-types.ts index 24a5c5056f..9e7c359530 100644 --- a/apps/web/src/components/security-agent/security-config-types.ts +++ b/apps/web/src/components/security-agent/security-config-types.ts @@ -1,4 +1,9 @@ import type { Repository } from '@/components/code-reviews/RepositoryMultiSelect'; +import { + DEFAULT_SECURITY_AGENT_ANALYSIS_MODEL, + DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, + DEFAULT_SECURITY_AGENT_TRIAGE_MODEL, +} from '@/lib/security-agent/core/constants'; import type { DependabotAlertsAvailability } from '@/lib/security-agent/core/types'; export type SlaConfig = { @@ -39,6 +44,7 @@ export type SecurityConfigFormState = { autoRemediationEnabled: boolean; autoRemediationMinSeverity: AutoRemediationMinSeverity; autoRemediationIncludeExisting: boolean; + autoRemediationRequireApproval: boolean; remediationModelSlug: string; slaNotificationsEnabled: boolean; slaNotificationMinSeverity: NotificationMinSeverity; @@ -52,6 +58,108 @@ export type SecurityConfigSavePayload = SlaConfig & modelSlug?: string; }; +/** The server-side config shape consumed when hydrating the settings form. */ +export type SecurityConfigFormSource = { + slaCriticalDays?: number; + slaHighDays?: number; + slaMediumDays?: number; + slaLowDays?: number; + slaEnabled?: boolean; + repositorySelectionMode?: RepositorySelectionMode; + selectedRepositoryIds?: number[]; + triageModelSlug?: string; + analysisModelSlug?: string; + modelSlug?: string; + analysisMode?: AnalysisMode; + autoDismissEnabled?: boolean; + autoDismissConfidenceThreshold?: AutoDismissConfidenceThreshold; + autoAnalysisEnabled?: boolean; + autoAnalysisMinSeverity?: AutoAnalysisMinSeverity; + autoAnalysisIncludeExisting?: boolean; + autoRemediationEnabled?: boolean; + autoRemediationMinSeverity?: AutoRemediationMinSeverity; + autoRemediationIncludeExisting?: boolean; + autoRemediationRequireApproval?: boolean; + remediationModelSlug?: string; + slaNotificationsEnabled?: boolean; + slaNotificationMinSeverity?: NotificationMinSeverity; + slaNotificationWarningDays?: number; + newFindingNotificationsEnabled?: boolean; + newFindingNotificationMinSeverity?: NotificationMinSeverity; +}; + +export function buildSecurityConfigFormState( + configData: SecurityConfigFormSource | undefined +): SecurityConfigFormState { + return { + slaConfig: { + critical: configData?.slaCriticalDays ?? 15, + high: configData?.slaHighDays ?? 30, + medium: configData?.slaMediumDays ?? 45, + low: configData?.slaLowDays ?? 90, + }, + slaEnabled: configData?.slaEnabled ?? true, + repositorySelectionMode: configData?.repositorySelectionMode ?? 'selected', + selectedRepositoryIds: configData?.selectedRepositoryIds ?? [], + triageModelSlug: + configData?.triageModelSlug ?? configData?.modelSlug ?? DEFAULT_SECURITY_AGENT_TRIAGE_MODEL, + analysisModelSlug: + configData?.analysisModelSlug ?? + configData?.modelSlug ?? + DEFAULT_SECURITY_AGENT_ANALYSIS_MODEL, + analysisMode: configData?.analysisMode ?? 'auto', + autoDismissEnabled: configData?.autoDismissEnabled ?? false, + autoDismissConfidenceThreshold: configData?.autoDismissConfidenceThreshold ?? 'high', + autoAnalysisEnabled: configData?.autoAnalysisEnabled ?? false, + autoAnalysisMinSeverity: configData?.autoAnalysisMinSeverity ?? 'high', + autoAnalysisIncludeExisting: configData?.autoAnalysisIncludeExisting ?? false, + autoRemediationEnabled: configData?.autoRemediationEnabled ?? false, + autoRemediationMinSeverity: configData?.autoRemediationMinSeverity ?? 'high', + autoRemediationIncludeExisting: configData?.autoRemediationIncludeExisting ?? false, + autoRemediationRequireApproval: configData?.autoRemediationRequireApproval ?? true, + remediationModelSlug: + configData?.remediationModelSlug ?? + configData?.analysisModelSlug ?? + configData?.modelSlug ?? + DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, + slaNotificationsEnabled: configData?.slaNotificationsEnabled ?? false, + slaNotificationMinSeverity: configData?.slaNotificationMinSeverity ?? 'high', + slaNotificationWarningDays: configData?.slaNotificationWarningDays ?? 3, + newFindingNotificationsEnabled: configData?.newFindingNotificationsEnabled ?? false, + newFindingNotificationMinSeverity: configData?.newFindingNotificationMinSeverity ?? 'high', + }; +} + +export function buildSecurityConfigSavePayload( + state: SecurityConfigFormState +): SecurityConfigSavePayload { + return { + ...state.slaConfig, + slaEnabled: state.slaEnabled, + repositorySelectionMode: state.repositorySelectionMode, + selectedRepositoryIds: state.selectedRepositoryIds, + triageModelSlug: state.triageModelSlug, + analysisModelSlug: state.analysisModelSlug, + modelSlug: state.analysisModelSlug, + analysisMode: state.analysisMode, + autoDismissEnabled: state.autoDismissEnabled, + autoDismissConfidenceThreshold: state.autoDismissConfidenceThreshold, + autoAnalysisEnabled: state.autoAnalysisEnabled, + autoAnalysisMinSeverity: state.autoAnalysisMinSeverity, + autoAnalysisIncludeExisting: state.autoAnalysisIncludeExisting, + autoRemediationEnabled: state.autoRemediationEnabled, + autoRemediationMinSeverity: state.autoRemediationMinSeverity, + autoRemediationIncludeExisting: state.autoRemediationIncludeExisting, + autoRemediationRequireApproval: state.autoRemediationRequireApproval, + remediationModelSlug: state.remediationModelSlug, + slaNotificationsEnabled: state.slaNotificationsEnabled, + slaNotificationMinSeverity: state.slaNotificationMinSeverity, + slaNotificationWarningDays: state.slaNotificationWarningDays, + newFindingNotificationsEnabled: state.newFindingNotificationsEnabled, + newFindingNotificationMinSeverity: state.newFindingNotificationMinSeverity, + }; +} + export function toRepositoryOptions(repositories: SecurityRepository[]): Repository[] { return repositories.map(repository => ({ id: repository.id, diff --git a/apps/web/src/lib/code-reviews/code-review-ledger.ts b/apps/web/src/lib/code-reviews/code-review-ledger.ts new file mode 100644 index 0000000000..5e417fd16f --- /dev/null +++ b/apps/web/src/lib/code-reviews/code-review-ledger.ts @@ -0,0 +1,137 @@ +/** + * Code Review operation ledger settle (P1-A-07c). + * + * The review row is admitted at creation (`createCodeReview`) and settled here + * once the review reaches a terminal state. `settleCodeReviewLedgerRow` is the + * best-effort form for sites whose terminalization already committed (the + * callback); `settleCodeReviewLedgerRowOn` throws so the reaper can roll back + * its terminalize transaction and retry. A missing admit row skips with a log. + */ +import { and, eq } from 'drizzle-orm'; +import { CODE_REVIEW_SETTLED_EVENT } from '@kilocode/app-shared/analytics'; +import { + settleOperation, + type LedgerDatabase, + type TerminalOperationStatus, +} from '@kilocode/db/operation-ledger'; +import { kilocode_users, operation_ledgers } from '@kilocode/db/schema'; +import type { CodeReviewTriggerSource } from '@kilocode/db/schema-types'; + +import { db } from '@/lib/drizzle'; +import { logExceptInTest } from '@/lib/utils.server'; + +/** + * The ledger intent for a review's trigger source. `trigger_source` is null + * for legacy rows, which predate webhook triggers and were always manual. + */ +export function codeReviewLedgerIntent(triggerSource: string | null): CodeReviewTriggerSource { + return triggerSource === 'webhook' ? 'webhook' : 'manual'; +} + +/** + * Maps a review terminal state to a terminal ledger outcome. Non-terminal + * states map to null, so a settle for a still-running review is a no-op. + */ +export function codeReviewTerminalOutcome( + status: string, + terminalReason: string | null +): TerminalOperationStatus | null { + switch (status) { + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'cancelled': + if (terminalReason === 'superseded') return 'superseded'; + if (terminalReason === 'interrupted') return 'interrupted'; + return 'no_op'; + default: + return null; + } +} + +/** + * Settles the admitted `code_review` ledger row using `database` (a pool or an + * open transaction), emitting exactly one `code_review_settled` outbox event. + * The settle is a compare-and-set from a non-terminal state, so a second call + * is a no-op. A missing admit row skips with a log. A settle failure throws so + * the caller can roll back an enclosing transaction. + */ +export async function settleCodeReviewLedgerRowOn( + database: LedgerDatabase, + params: { + reviewId: string; + status: string; + terminalReason: string | null; + triggerSource: string | null; + } +): Promise { + const outcome = codeReviewTerminalOutcome(params.status, params.terminalReason); + if (!outcome) return; + + const [row] = await database + .select({ + id: operation_ledgers.id, + kilo_user_id: operation_ledgers.kilo_user_id, + admitted_at: operation_ledgers.admitted_at, + google_user_email: kilocode_users.google_user_email, + }) + .from(operation_ledgers) + .leftJoin(kilocode_users, eq(kilocode_users.id, operation_ledgers.kilo_user_id)) + .where( + and( + eq(operation_ledgers.domain, 'code_review'), + eq(operation_ledgers.operation_key, `review:${params.reviewId}`) + ) + ) + .limit(1); + + if (!row) { + logExceptInTest('[code-review-ledger] No admitted ledger row to settle', { + reviewId: params.reviewId, + }); + return; + } + + await settleOperation(database, { + rowId: row.id, + status: outcome, + outboxEvent: { + eventName: CODE_REVIEW_SETTLED_EVENT, + // The outbox contract documents `distinctId` as the user's email, so + // resolve it first and fall back to the raw id, matching the sibling + // PR and security emitters (`google_user_email ?? id`). + distinctId: row.google_user_email ?? row.kilo_user_id, + properties: { + source: 'web', + surface: 'code_review', + phase: 'terminal', + intent: codeReviewLedgerIntent(params.triggerSource), + outcome, + duration_ms: Math.max(0, Date.now() - new Date(row.admitted_at).getTime()), + }, + }, + }); +} + +/** + * Settles the admitted `code_review` ledger row for a review, emitting exactly + * one `code_review_settled` outbox event. The settle is a compare-and-set from + * a non-terminal state, so a second call is a no-op. A missing admit row skips + * with a log; a ledger write failure is logged and never thrown. + */ +export async function settleCodeReviewLedgerRow(params: { + reviewId: string; + status: string; + terminalReason: string | null; + triggerSource: string | null; +}): Promise { + try { + await settleCodeReviewLedgerRowOn(db, params); + } catch (error) { + logExceptInTest('[code-review-ledger] Failed to settle code review ledger row', { + reviewId: params.reviewId, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/apps/web/src/lib/code-reviews/code-review-settled-outcomes.test.ts b/apps/web/src/lib/code-reviews/code-review-settled-outcomes.test.ts new file mode 100644 index 0000000000..99118857f2 --- /dev/null +++ b/apps/web/src/lib/code-reviews/code-review-settled-outcomes.test.ts @@ -0,0 +1,255 @@ +/** + * @jest-environment node + * + * Code Review settled-outcome coverage (P1-A-07c). Runs against the migrated + * test database: `admitOperation` admits a `code_review` row and + * `settleCodeReviewLedgerRow` settles it through the real ledger helpers, so + * the assertions cover the actual outbox row and the deterministic + * settle-plus-outbox atomicity. + */ +import { randomUUID } from 'crypto'; +import { and, eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/drizzle'; +import { analytics_event_outbox, kilocode_users, operation_ledgers } from '@kilocode/db/schema'; +import { admitOperation } from '@kilocode/db/operation-ledger'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + codeReviewLedgerIntent, + codeReviewTerminalOutcome, + settleCodeReviewLedgerRow, +} from './code-review-ledger'; + +async function admitReview(reviewId: string, intent: 'manual' | 'webhook' = 'manual') { + return admitOperation(db, { + userId: 'review-owner', + domain: 'code_review', + intent, + operationKey: `review:${reviewId}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); +} + +describe('code review settled outcomes', () => { + beforeEach(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + afterAll(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + describe('codeReviewLedgerIntent', () => { + it('maps webhook to webhook and everything else, including null legacy rows, to manual', () => { + expect(codeReviewLedgerIntent('webhook')).toBe('webhook'); + expect(codeReviewLedgerIntent('manual')).toBe('manual'); + expect(codeReviewLedgerIntent(null)).toBe('manual'); + }); + }); + + describe('codeReviewTerminalOutcome', () => { + it('maps terminal review states to ledger outcomes', () => { + expect(codeReviewTerminalOutcome('completed', null)).toBe('completed'); + expect(codeReviewTerminalOutcome('failed', 'timeout')).toBe('failed'); + expect(codeReviewTerminalOutcome('cancelled', 'superseded')).toBe('superseded'); + expect(codeReviewTerminalOutcome('cancelled', 'interrupted')).toBe('interrupted'); + expect(codeReviewTerminalOutcome('cancelled', 'model_not_found')).toBe('no_op'); + expect(codeReviewTerminalOutcome('cancelled', 'user_cancelled')).toBe('no_op'); + }); + + it('returns null for non-terminal states', () => { + expect(codeReviewTerminalOutcome('pending', null)).toBeNull(); + expect(codeReviewTerminalOutcome('queued', null)).toBeNull(); + expect(codeReviewTerminalOutcome('running', null)).toBeNull(); + }); + }); + + describe('settleCodeReviewLedgerRow', () => { + it('resolves the user email for distinctId, matching the sibling emitters', async () => { + const user = await insertTestUser(); + const reviewId = randomUUID(); + await admitOperation(db, { + userId: user.id, + domain: 'code_review', + intent: 'manual', + operationKey: `review:${reviewId}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); + + await settleCodeReviewLedgerRow({ + reviewId, + status: 'completed', + terminalReason: null, + triggerSource: 'manual', + }); + + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.distinct_id).toBe(user.google_user_email); + + await db.delete(kilocode_users).where(eq(kilocode_users.id, user.id)); + }); + + it('falls back to the raw user id when no user row exists', async () => { + const reviewId = randomUUID(); + await admitOperation(db, { + userId: 'missing-user', + domain: 'code_review', + intent: 'manual', + operationKey: `review:${reviewId}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); + + await settleCodeReviewLedgerRow({ + reviewId, + status: 'completed', + terminalReason: null, + triggerSource: 'manual', + }); + + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.distinct_id).toBe('missing-user'); + }); + + it('emits exactly one code_review_settled outbox row with only the contract keys', async () => { + const reviewId = randomUUID(); + await admitReview(reviewId, 'webhook'); + + await settleCodeReviewLedgerRow({ + reviewId, + status: 'completed', + terminalReason: null, + triggerSource: 'webhook', + }); + + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_name).toBe('code_review_settled'); + expect(rows[0]?.distinct_id).toBe('review-owner'); + expect(Object.keys(rows[0]?.properties ?? {}).sort()).toEqual([ + 'duration_ms', + 'intent', + 'outcome', + 'phase', + 'source', + 'surface', + ]); + expect(rows[0]?.properties).toMatchObject({ + source: 'web', + surface: 'code_review', + phase: 'terminal', + intent: 'webhook', + outcome: 'completed', + }); + }); + + it('emits once per settle site and is double-run safe', async () => { + // Site (a): analytics completion. + const completedId = randomUUID(); + await admitReview(completedId, 'manual'); + await settleCodeReviewLedgerRow({ + reviewId: completedId, + status: 'completed', + terminalReason: null, + triggerSource: 'manual', + }); + + // Site (b): model-not-found cancellation maps to no_op. + const cancelledId = randomUUID(); + await admitReview(cancelledId, 'webhook'); + await settleCodeReviewLedgerRow({ + reviewId: cancelledId, + status: 'cancelled', + terminalReason: 'model_not_found', + triggerSource: 'webhook', + }); + + // Site (c): reaper failure maps to failed. + const failedId = randomUUID(); + await admitReview(failedId, 'manual'); + await settleCodeReviewLedgerRow({ + reviewId: failedId, + status: 'failed', + terminalReason: 'abandoned', + triggerSource: 'manual', + }); + + // Double-run safety: settling site (b) again must not emit a second row. + await settleCodeReviewLedgerRow({ + reviewId: cancelledId, + status: 'cancelled', + terminalReason: 'model_not_found', + triggerSource: 'webhook', + }); + + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(3); + expect(rows.map(row => (row.properties as { outcome: string }).outcome).sort()).toEqual([ + 'completed', + 'failed', + 'no_op', + ]); + }); + + it('maps interrupted→cancelled to the interrupted outcome and emits one terminal event', async () => { + const reviewId = randomUUID(); + await admitReview(reviewId, 'manual'); + + await settleCodeReviewLedgerRow({ + reviewId, + status: 'cancelled', + terminalReason: 'interrupted', + triggerSource: 'manual', + }); + + const rows = await db.select().from(analytics_event_outbox); + expect(rows).toHaveLength(1); + expect(rows[0]?.properties).toMatchObject({ outcome: 'interrupted', intent: 'manual' }); + }); + + it('skips without throwing when no admit row exists', async () => { + const reviewId = randomUUID(); + + await expect( + settleCodeReviewLedgerRow({ + reviewId, + status: 'completed', + terminalReason: null, + triggerSource: 'manual', + }) + ).resolves.toBeUndefined(); + + expect(await db.select().from(analytics_event_outbox)).toHaveLength(0); + }); + + it('leaves the row admitted and emits nothing for a non-terminal review', async () => { + const reviewId = randomUUID(); + await admitReview(reviewId, 'manual'); + + await settleCodeReviewLedgerRow({ + reviewId, + status: 'running', + terminalReason: null, + triggerSource: 'manual', + }); + + expect(await db.select().from(analytics_event_outbox)).toHaveLength(0); + const [row] = await db + .select() + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.domain, 'code_review'), + eq(operation_ledgers.operation_key, `review:${reviewId}`) + ) + ); + expect(row?.status).toBe('admitted'); + }); + }); +}); diff --git a/apps/web/src/lib/code-reviews/core/schemas.ts b/apps/web/src/lib/code-reviews/core/schemas.ts index 4b0c8fd794..441236da16 100644 --- a/apps/web/src/lib/code-reviews/core/schemas.ts +++ b/apps/web/src/lib/code-reviews/core/schemas.ts @@ -6,7 +6,6 @@ */ import * as z from 'zod'; -import type { CloudAgentCodeReview } from '@kilocode/db/schema'; import { CODE_REVIEW_PLATFORMS, ManualCodeReviewConfigSchema, @@ -235,16 +234,3 @@ export type CreateReviewParams = z.infer; export type UpdateReviewStatusParams = z.infer; export type ListReviewsParams = z.infer; export type TriggerReviewParams = z.infer; - -/** - * Response type for list code reviews - */ -// List rows exclude the potentially large `council_result` JSONB — the jobs list is -// polled frequently and never renders council results (only the review-detail path does). -export type CodeReviewListItem = Omit; - -export type ListCodeReviewsResponse = { - reviews: CodeReviewListItem[]; - total: number; - hasMore: boolean; -}; diff --git a/apps/web/src/lib/code-reviews/db/code-reviews-ledger.test.ts b/apps/web/src/lib/code-reviews/db/code-reviews-ledger.test.ts new file mode 100644 index 0000000000..29664c87f7 --- /dev/null +++ b/apps/web/src/lib/code-reviews/db/code-reviews-ledger.test.ts @@ -0,0 +1,275 @@ +/** + * @jest-environment node + * + * Coverage for the best-effort ledger contracts in `createCodeReview` + * (P1-A-07c) and the cancel wrappers. `admitOperation` and `settleOperation` + * are mocked so a rejected admit or settle can be exercised; the review insert + * and cancel still run against the real test DB. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +const mockAdmitOperation = jest.fn, unknown[]>(); +const mockSettleOperation = jest.fn, unknown[]>(); + +jest.mock('@kilocode/db/operation-ledger', () => { + const actual = jest.requireActual('@kilocode/db/operation-ledger'); + return { + ...actual, + admitOperation: (...args: unknown[]) => mockAdmitOperation(...args), + settleOperation: (...args: unknown[]) => mockSettleOperation(...args), + }; +}); + +import { codeReviewTerminalOutcome } from '../code-review-ledger'; +import { db } from '@/lib/drizzle'; +import { + agent_configs, + cloud_agent_code_reviews, + kilocode_users, + organizations, + platform_integrations, +} from '@kilocode/db/schema'; +import { and, eq, inArray } from 'drizzle-orm'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import type { User } from '@kilocode/db/schema'; +import { + cancelActiveCodeReviewsById, + createCodeReview, + disableBitbucketCodeReviewerForIntegration, + updateCodeReviewStatus, +} from './code-reviews'; + +const REPO = `test-org/ledger-admit-reject-${Date.now()}`; + +describe('createCodeReview ledger admit is best-effort', () => { + let testUser: User; + let githubIntegrationId: string; + const createdReviewIds: string[] = []; + + beforeAll(async () => { + testUser = await insertTestUser(); + const [githubIntegration] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: testUser.id, + platform: 'github', + integration_type: 'app', + platform_installation_id: `ledger-admit-reject-${Date.now()}`, + platform_account_id: 'ledger-admit-reject', + platform_account_login: 'ledger-admit-reject', + repository_access: 'all', + integration_status: 'active', + }) + .returning({ id: platform_integrations.id }); + if (!githubIntegration) { + throw new Error('Expected ledger admit reject integration'); + } + githubIntegrationId = githubIntegration.id; + }); + + afterAll(async () => { + for (const id of createdReviewIds) { + await db.delete(cloud_agent_code_reviews).where(eq(cloud_agent_code_reviews.id, id)); + } + await db.delete(platform_integrations).where(eq(platform_integrations.id, githubIntegrationId)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id)); + }); + + it('still returns the review id when the ledger admit rejects', async () => { + mockAdmitOperation.mockRejectedValue(new Error('database unavailable')); + + const reviewId = await createCodeReview({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platformIntegrationId: githubIntegrationId, + repoFullName: REPO, + prNumber: 1, + prUrl: `https://github.com/${REPO}/pull/1`, + prTitle: 'ledger admit reject', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-admit-reject', + headSha: 'ledger-admit-reject-head-sha', + platform: 'github', + triggerSource: 'manual', + }); + createdReviewIds.push(reviewId); + + expect(reviewId).toEqual(expect.any(String)); + expect(mockAdmitOperation).toHaveBeenCalledTimes(1); + }); +}); + +describe('codeReviewTerminalOutcome', () => { + it('maps superseded and user-cancelled cancellations to terminal outcomes', () => { + expect(codeReviewTerminalOutcome('cancelled', 'superseded')).toBe('superseded'); + expect(codeReviewTerminalOutcome('cancelled', 'user_cancelled')).toBe('no_op'); + expect(codeReviewTerminalOutcome('cancelled', 'interrupted')).toBe('interrupted'); + expect(codeReviewTerminalOutcome('pending', null)).toBeNull(); + }); +}); + +describe('cancel settle is best-effort', () => { + let testUser: User; + let organizationId: string; + let githubIntegrationId: string; + let bitbucketIntegrationId: string; + const createdReviewIds: string[] = []; + + beforeAll(async () => { + testUser = await insertTestUser(); + const [organization] = await db + .insert(organizations) + .values({ name: `Ledger settle ${Date.now()}` }) + .returning({ id: organizations.id }); + if (!organization) { + throw new Error('Expected ledger settle organization'); + } + organizationId = organization.id; + + const [githubIntegration] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: testUser.id, + platform: 'github', + integration_type: 'app', + platform_installation_id: `ledger-settle-github-${Date.now()}`, + platform_account_id: 'ledger-settle-github', + platform_account_login: 'ledger-settle-github', + repository_access: 'all', + integration_status: 'active', + }) + .returning({ id: platform_integrations.id }); + if (!githubIntegration) { + throw new Error('Expected ledger settle github integration'); + } + githubIntegrationId = githubIntegration.id; + + const [bitbucketIntegration] = await db + .insert(platform_integrations) + .values({ + owned_by_organization_id: organizationId, + platform: 'bitbucket', + integration_type: 'oauth', + platform_installation_id: `ledger-settle-bitbucket-${Date.now()}`, + platform_account_id: 'ledger-settle-bitbucket', + platform_account_login: 'ledger-settle-bitbucket', + repository_access: 'selected', + integration_status: 'active', + }) + .returning({ id: platform_integrations.id }); + if (!bitbucketIntegration) { + throw new Error('Expected ledger settle bitbucket integration'); + } + bitbucketIntegrationId = bitbucketIntegration.id; + }); + + afterAll(async () => { + if (createdReviewIds.length > 0) { + await db + .delete(cloud_agent_code_reviews) + .where(inArray(cloud_agent_code_reviews.id, createdReviewIds)); + } + await db + .delete(agent_configs) + .where(eq(agent_configs.owned_by_organization_id, organizationId)); + await db + .delete(platform_integrations) + .where(inArray(platform_integrations.id, [githubIntegrationId, bitbucketIntegrationId])); + await db.delete(organizations).where(eq(organizations.id, organizationId)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id)); + }); + + beforeEach(() => { + jest.clearAllMocks(); + const actual = jest.requireActual('@kilocode/db/operation-ledger') as { + admitOperation: (...args: unknown[]) => Promise; + settleOperation: (...args: unknown[]) => Promise; + }; + mockAdmitOperation.mockImplementation(actual.admitOperation); + mockSettleOperation.mockRejectedValue(new Error('ledger unavailable')); + }); + + it('still cancels reviews when the ledger settle fails (cancelActiveCodeReviewsById)', async () => { + const reviewId = await createCodeReview({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platformIntegrationId: githubIntegrationId, + repoFullName: `${REPO}-settle-by-id`, + prNumber: 1, + prUrl: `https://github.com/${REPO}-settle-by-id/pull/1`, + prTitle: 'ledger settle by id', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-settle-by-id', + headSha: 'ledger-settle-by-id-head-sha', + platform: 'github', + triggerSource: 'manual', + }); + createdReviewIds.push(reviewId); + + const cancelled = await cancelActiveCodeReviewsById([reviewId], 'Superseded by new push'); + + expect(cancelled.map(row => row.id)).toContain(reviewId); + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + const [review] = await db + .select({ status: cloud_agent_code_reviews.status }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.id, reviewId)); + expect(review?.status).toBe('cancelled'); + }); + + it('still disables and cancels when the ledger settle fails (disableBitbucketCodeReviewerForIntegration)', async () => { + await db.insert(agent_configs).values({ + owned_by_organization_id: organizationId, + agent_type: 'code_review', + platform: 'bitbucket', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: ['22222222-2222-4222-8222-222222222222'], + }, + is_enabled: true, + created_by: testUser.id, + }); + const reviewId = await createCodeReview({ + owner: { type: 'org', id: organizationId, userId: testUser.id }, + platformIntegrationId: bitbucketIntegrationId, + repoFullName: `${REPO}-settle-disable`, + prNumber: 2, + prUrl: `https://bitbucket.org/${REPO}-settle-disable/pull-requests/2`, + prTitle: 'ledger settle disable', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-settle-disable', + headSha: 'ledger-settle-disable-head-sha', + platform: 'bitbucket', + triggerSource: 'manual', + }); + createdReviewIds.push(reviewId); + await updateCodeReviewStatus(reviewId, 'queued'); + + const cancelled = await disableBitbucketCodeReviewerForIntegration({ + organizationId, + integrationId: bitbucketIntegrationId, + }); + + expect(cancelled.map(row => row.id)).toContain(reviewId); + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + const [review] = await db + .select({ status: cloud_agent_code_reviews.status }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.id, reviewId)); + expect(review?.status).toBe('cancelled'); + const [config] = await db + .select({ isEnabled: agent_configs.is_enabled }) + .from(agent_configs) + .where( + and( + eq(agent_configs.owned_by_organization_id, organizationId), + eq(agent_configs.platform, 'bitbucket') + ) + ); + expect(config?.isEnabled).toBe(false); + }); +}); diff --git a/apps/web/src/lib/code-reviews/db/code-reviews.test.ts b/apps/web/src/lib/code-reviews/db/code-reviews.test.ts index 141374d795..22f2a944b9 100644 --- a/apps/web/src/lib/code-reviews/db/code-reviews.test.ts +++ b/apps/web/src/lib/code-reviews/db/code-reviews.test.ts @@ -6,12 +6,14 @@ import { kilocode_users, microdollar_usage, microdollar_usage_metadata, + operation_ledgers, organizations, platform_integrations, } from '@kilocode/db/schema'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, getTableColumns, inArray } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import type { User } from '@kilocode/db/schema'; +import type { CodeReviewCouncilResult, ManualCodeReviewConfig } from '@kilocode/db/schema-types'; import { bitbucketCodeReviewerLifecycleLockKey, cancelActiveCodeReviewsById, @@ -26,8 +28,10 @@ import { findActiveReviewsForPR, findExistingReview, getCodeReviewAttemptForReview, + getCodeReviewCouncilResult, getSessionUsageFromBilling, listCodeReviewAttempts, + listCodeReviews, updateCodeReviewAttemptForCallback, findPreviousCompletedReview, updateCodeReviewStatus, @@ -701,6 +705,12 @@ describe('review identity', () => { expect(review?.status).toBe('cancelled'); expect(storedAttempt?.status).toBe('cancelled'); + const [ledgerRow] = await db + .select({ status: operation_ledgers.status }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + expect(ledgerRow?.status).toBe('no_op'); + await db .delete(agent_configs) .where( @@ -787,6 +797,110 @@ describe('review identity', () => { .where(inArray(cloud_agent_code_review_attempts.id, [queuedAttempt.id, runningAttempt.id])); expect(attempts.map(attempt => attempt.status)).toEqual(['cancelled', 'cancelled']); }); + + it('settles the admitted ledger row for user-cancelled reviews', async () => { + const reviewId = await createCodeReview({ + owner: { type: 'org', id: organizationId, userId: firstUser.id }, + platformIntegrationId: organizationIntegrationId, + repoFullName: `${REPO}-ledger-user-cancel`, + prNumber: 36, + prUrl: `https://github.com/${REPO}-ledger-user-cancel/pull/36`, + prTitle: 'ledger user cancel settle', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-user-cancel-settle', + headSha: 'ledger-user-cancel-settle-head-sha', + platform: 'github', + triggerSource: 'manual', + }); + createdReviewIds.push(reviewId); + + const cancelled = await cancelActiveCodeReviewsForIntegration({ + organizationId, + platform: 'github', + integrationId: organizationIntegrationId, + }); + + expect(cancelled.map(row => row.id)).toContain(reviewId); + expect(cancelled.find(row => row.id === reviewId)?.triggerSource).toBe('manual'); + + const [ledgerRow] = await db + .select({ status: operation_ledgers.status }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + expect(ledgerRow?.status).toBe('no_op'); + }); + + it('admits a code_review ledger row with the mapped intent on create', async () => { + const reviewId = await createCodeReview({ + owner: { type: 'user', id: firstUser.id, userId: firstUser.id }, + platformIntegrationId: firstIntegrationId, + repoFullName: `${REPO}-ledger-admit`, + prNumber: 34, + prUrl: `https://github.com/${REPO}-ledger-admit/pull/34`, + prTitle: 'ledger admit', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-admit', + headSha: 'ledger-admit-head-sha', + platform: 'github', + triggerSource: 'webhook', + }); + createdReviewIds.push(reviewId); + + const [ledgerRow] = await db + .select({ + domain: operation_ledgers.domain, + operationKey: operation_ledgers.operation_key, + intent: operation_ledgers.intent, + }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + + expect(ledgerRow).toEqual({ + domain: 'code_review', + operationKey: `review:${reviewId}`, + intent: 'webhook', + }); + }); + + it('does not admit a code_review ledger row inside the creation transaction', async () => { + const repoFullName = `${REPO}-ledger-transaction-no-admit`; + const result = await db.transaction(tx => + createCodeReviewIfAbsentInTransaction( + tx, + { + owner: { type: 'user', id: firstUser.id, userId: firstUser.id }, + platform: 'github', + repoFullName, + prNumber: 35, + }, + { + owner: { type: 'user', id: firstUser.id, userId: firstUser.id }, + platformIntegrationId: firstIntegrationId, + repoFullName, + prNumber: 35, + prUrl: `https://github.com/${repoFullName}/pull/35`, + prTitle: 'ledger transaction no admit', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-transaction-no-admit', + headSha: 'ledger-transaction-no-admit-head-sha', + platform: 'github', + triggerSource: 'webhook', + } + ) + ); + expect(result.created).toBe(true); + createdReviewIds.push(result.reviewId); + + const ledgerRows = await db + .select({ id: operation_ledgers.id }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${result.reviewId}`)); + + expect(ledgerRows).toHaveLength(0); + }); }); describe('cancelSupersededReviewsForPR', () => { @@ -1172,6 +1286,44 @@ describe('cancelSupersededReviewsForPR', () => { .limit(1); expect(otherRepoRow?.status).toBe('pending'); }); + + it('settles the admitted ledger row for superseded reviews', async () => { + const reviewId = await createCodeReview({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platformIntegrationId: githubIntegrationId, + repoFullName: repo, + prNumber: 46, + prUrl: `https://github.com/${repo}/pull/46`, + prTitle: 'ledger superseded settle', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/ledger-superseded-settle', + headSha: 'sha-ledger-superseded-settle', + platform: 'github', + triggerSource: 'webhook', + }); + createdReviewIds.push(reviewId); + + const cancelled = await cancelSupersededReviewsForPR( + { + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platform: 'github', + repoFullName: repo, + prNumber: 46, + platformIntegrationId: githubIntegrationId, + }, + 'sha-ledger-superseded-settle-new' + ); + + expect(cancelled.map(row => row.id)).toEqual([reviewId]); + expect(cancelled[0]?.triggerSource).toBe('webhook'); + + const [ledgerRow] = await db + .select({ status: operation_ledgers.status }) + .from(operation_ledgers) + .where(eq(operation_ledgers.operation_key, `review:${reviewId}`)); + expect(ledgerRow?.status).toBe('superseded'); + }); }); describe('findPreviousCompletedReview', () => { @@ -1819,3 +1971,111 @@ describe('resetCodeReviewForRetry', () => { expect(stored?.status).toBe('pending'); }); }); + +describe('listCodeReviews narrows the list DTO', () => { + let testUser: User; + let integrationId: string; + const createdReviewIds: string[] = []; + + beforeAll(async () => { + testUser = await insertTestUser(); + const [integration] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: testUser.id, + platform: 'github', + integration_type: 'app', + platform_installation_id: `narrow-list-${Date.now()}`, + platform_account_id: 'narrow-list', + platform_account_login: 'narrow-list', + repository_access: 'all', + integration_status: 'active', + }) + .returning({ id: platform_integrations.id }); + if (!integration) { + throw new Error('Expected narrow-list integration'); + } + integrationId = integration.id; + }); + + afterAll(async () => { + for (const id of createdReviewIds) { + await db.delete(cloud_agent_code_reviews).where(eq(cloud_agent_code_reviews.id, id)); + } + await db.delete(platform_integrations).where(eq(platform_integrations.id, integrationId)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id)); + }); + + async function createReviewWithHeavyFields() { + const reviewId = await createCodeReview({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platformIntegrationId: integrationId, + repoFullName: `${REPO}-narrow-list`, + prNumber: 61, + prUrl: `https://github.com/${REPO}-narrow-list/pull/61`, + prTitle: 'narrow list DTO', + prAuthor: 'octocat', + baseRef: 'main', + headRef: 'feature/narrow-list', + headSha: 'narrow-list-head-sha', + platform: 'github', + }); + createdReviewIds.push(reviewId); + await db + .update(cloud_agent_code_reviews) + .set({ + council_result: { + decision: 'pass', + aggregationStrategy: 'unanimous', + specialists: [], + } as CodeReviewCouncilResult, + manual_config: { + outputMode: 'kilo', + instructions: null, + agentConfig: { model_slug: 'test-model' }, + } as ManualCodeReviewConfig, + previous_summary_body: 'previous summary body', + }) + .where(eq(cloud_agent_code_reviews.id, reviewId)); + return reviewId; + } + + it('omits council_result, manual_config, and previous_summary_body from list rows', async () => { + const reviewId = await createReviewWithHeavyFields(); + + const rows = await listCodeReviews({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + limit: 50, + offset: 0, + }); + + const row = rows.find(r => r.id === reviewId); + expect(row).toBeDefined(); + if (!row) { + throw new Error('Expected narrow-list row'); + } + expect(row).not.toHaveProperty('council_result'); + expect(row).not.toHaveProperty('manual_config'); + expect(row).not.toHaveProperty('previous_summary_body'); + + const { + council_result: _councilResult, + manual_config: _manualConfig, + previous_summary_body: _previousSummaryBody, + ...listColumns + } = getTableColumns(cloud_agent_code_reviews); + expect(Object.keys(row).sort()).toEqual(Object.keys(listColumns).sort()); + }); + + it('keeps council_result available to the detail getter', async () => { + const reviewId = await createReviewWithHeavyFields(); + + const councilResult = await getCodeReviewCouncilResult(reviewId); + + expect(councilResult).toEqual({ + decision: 'pass', + aggregationStrategy: 'unanimous', + specialists: [], + }); + }); +}); diff --git a/apps/web/src/lib/code-reviews/db/code-reviews.ts b/apps/web/src/lib/code-reviews/db/code-reviews.ts index 26e862c6f1..00435b7e2b 100644 --- a/apps/web/src/lib/code-reviews/db/code-reviews.ts +++ b/apps/web/src/lib/code-reviews/db/code-reviews.ts @@ -14,6 +14,7 @@ import { microdollar_usage, microdollar_usage_metadata, } from '@kilocode/db/schema'; +import { admitOperation, type LedgerDatabase } from '@kilocode/db/operation-ledger'; import { eq, and, @@ -30,8 +31,10 @@ import { getTableColumns, } from 'drizzle-orm'; import { captureException } from '@sentry/nextjs'; -import { CreateReviewParamsSchema, type CodeReviewListItem } from '../core'; +import { logExceptInTest } from '@/lib/utils.server'; +import { CreateReviewParamsSchema } from '../core'; import { assertCouncilCreationAllowed } from '../core/council-entitlement'; +import { codeReviewLedgerIntent, settleCodeReviewLedgerRow } from '../code-review-ledger'; import type { CodeReviewPlatform, CreateReviewParams, @@ -165,6 +168,7 @@ export type CancelledReviewRow = { platform: CodeReviewPlatform; platformProjectId: number | null; platformIntegrationId: string | null; + triggerSource: string | null; }; type CodeReviewDatabase = typeof db | DrizzleTransaction; @@ -226,6 +230,55 @@ function codeReviewInsertValues( }; } +/** + * Admits a `code_review`-domain ledger row for a newly created review using + * `database` (a pool or an open transaction). The terminal settle later joins + * by the operation key `review:`. A failure throws so the caller can + * roll back an enclosing transaction; callers whose review row already + * committed must use the best-effort `admitCodeReviewLedgerRow` wrapper. + */ +async function admitCodeReviewLedgerRowOn( + database: LedgerDatabase, + params: { + reviewId: string; + userId: string; + orgId?: string | null; + triggerSource: string | null; + } +): Promise { + await admitOperation(database, { + userId: params.userId, + orgId: params.orgId ?? null, + domain: 'code_review', + intent: codeReviewLedgerIntent(params.triggerSource), + operationKey: `review:${params.reviewId}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); +} + +/** + * Admits a `code_review`-domain ledger row for a newly created review using the + * pool `db`. Best-effort: the review row already committed, so a ledger write + * failure must not fail review creation; the terminal settle then skips on the + * missing row. A ledger write failure is logged and never thrown. + */ +export async function admitCodeReviewLedgerRow(params: { + reviewId: string; + userId: string; + orgId?: string | null; + triggerSource: string | null; +}): Promise { + try { + await admitCodeReviewLedgerRowOn(db, params); + } catch (error) { + logExceptInTest('[code-review-ledger] Failed to admit code review ledger row', { + reviewId: params.reviewId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * Creates a new code review record * Returns the created review ID @@ -242,6 +295,15 @@ export async function createCodeReview(params: CreateReviewParams): Promise { +export type CodeReviewListRow = Omit< + CloudAgentCodeReview, + 'council_result' | 'manual_config' | 'previous_summary_body' +>; + +export async function listCodeReviews(params: ListReviewsParams): Promise { try { const { owner, limit = 50, offset = 0, status, repoFullName, platform } = params; @@ -1390,9 +1457,15 @@ export async function listCodeReviews(params: ListReviewsParams): Promise { + for (const row of cancelled) { + await settleCodeReviewLedgerRow({ + reviewId: row.id, + status: 'cancelled', + terminalReason, + triggerSource: row.triggerSource, + }); + } +} + export async function cancelActiveCodeReviewsForIntegration( input: IntegrationReviewCancellationInput ): Promise { try { - return await cancelActiveCodeReviewsForIntegrationWithDatabase( + const cancelled = await cancelActiveCodeReviewsForIntegrationWithDatabase( db, input, 'Platform integration disconnected' ); + await settleCancelledReviews(cancelled, 'user_cancelled'); + return cancelled; } catch (error) { captureException(error, { tags: { operation: 'cancelActiveCodeReviewsForIntegration' }, @@ -1947,7 +2051,9 @@ export async function cancelActiveCodeReviewsById( errorMessage: string ): Promise { try { - return await cancelActiveCodeReviewsByIdWithDatabase(db, reviewIds, errorMessage); + const cancelled = await cancelActiveCodeReviewsByIdWithDatabase(db, reviewIds, errorMessage); + await settleCancelledReviews(cancelled, 'superseded'); + return cancelled; } catch (error) { captureException(error, { tags: { operation: 'cancelActiveCodeReviewsById' }, @@ -1962,7 +2068,7 @@ export async function disableBitbucketCodeReviewerForIntegration(input: { integrationId: string; }): Promise { try { - return await db.transaction(async tx => { + const cancelled = await db.transaction(async tx => { const lockKey = bitbucketCodeReviewerLifecycleLockKey(input.integrationId); await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); await tx @@ -1984,6 +2090,10 @@ export async function disableBitbucketCodeReviewerForIntegration(input: { 'Bitbucket Code Reviewer disabled' ); }); + // Settle best-effort after commit: a settle failure must not roll back the + // disable or the cancel. + await settleCancelledReviews(cancelled, 'user_cancelled'); + return cancelled; } catch (error) { captureException(error, { tags: { operation: 'disableBitbucketCodeReviewerForIntegration' }, @@ -1998,7 +2108,9 @@ export async function cancelSupersededReviewsForPR( excludeSha: string ): Promise { try { - return await cancelReviewsForPR(db, scope, excludeSha); + const cancelled = await cancelReviewsForPR(db, scope, excludeSha); + await settleCancelledReviews(cancelled, 'superseded'); + return cancelled; } catch (error) { captureException(error, { tags: { operation: 'cancelSupersededReviewsForPR' }, diff --git a/apps/web/src/lib/code-reviews/reap-stale-reviews.integration.test.ts b/apps/web/src/lib/code-reviews/reap-stale-reviews.integration.test.ts index 6fff2c0689..fac5b79c1d 100644 --- a/apps/web/src/lib/code-reviews/reap-stale-reviews.integration.test.ts +++ b/apps/web/src/lib/code-reviews/reap-stale-reviews.integration.test.ts @@ -1,10 +1,13 @@ import { db } from '@/lib/drizzle'; import { + analytics_event_outbox, cloud_agent_code_review_attempts, cloud_agent_code_reviews, + operation_ledgers, type User, } from '@kilocode/db/schema'; import { eq, inArray, sql } from 'drizzle-orm'; +import { admitOperation } from '@kilocode/db/operation-ledger'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { reapStaleCodeReviews } from './reap-stale-reviews'; @@ -34,6 +37,8 @@ describe('reapStaleCodeReviews against the database', () => { .delete(cloud_agent_code_reviews) .where(inArray(cloud_agent_code_reviews.id, createdReviewIds)); } + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); }); async function insertReview(params: { status: string; hoursOld: number }): Promise { @@ -124,4 +129,29 @@ describe('reapStaleCodeReviews against the database', () => { // status is terminal, so the selection predicate excludes it structurally. expect(secondRun.selected).toBeGreaterThanOrEqual(0); }); + + it('settles the admitted ledger row and emits one code_review_settled outbox row', async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + + const reviewId = await insertReview({ status: 'running', hoursOld: 72 }); + await admitOperation(db, { + userId: user.id, + domain: 'code_review', + intent: 'manual', + operationKey: `review:${reviewId}`, + taxonomy: 'never-replay', + leaseSeconds: 60, + }); + + await reapStaleCodeReviews(500); + + const rows = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.event_name, 'code_review_settled')); + expect(rows).toHaveLength(1); + expect(rows[0]?.distinct_id).toBe(user.google_user_email); + expect(rows[0]?.properties).toMatchObject({ outcome: 'failed' }); + }); }); diff --git a/apps/web/src/lib/code-reviews/reap-stale-reviews.test.ts b/apps/web/src/lib/code-reviews/reap-stale-reviews.test.ts index 426a913329..da5041df96 100644 --- a/apps/web/src/lib/code-reviews/reap-stale-reviews.test.ts +++ b/apps/web/src/lib/code-reviews/reap-stale-reviews.test.ts @@ -12,6 +12,9 @@ const mockSetCommitStatus = jest.fn() as jest.MockedFunction<(...args: any[]) => const mockResolveGitLabAccessToken = jest.fn() as jest.MockedFunction< (...args: any[]) => Promise >; +const mockSettleCodeReviewLedgerRowOn = jest.fn() as jest.MockedFunction< + (...args: any[]) => Promise +>; // The real SQL (selection predicate, optimistic lock, attempt terminalization) // is exercised against the database in reap-stale-reviews.integration.test.ts; @@ -77,6 +80,10 @@ jest.mock('@/lib/code-reviews/platform/gitlab-access', () => ({ getGitLabInstanceUrl: () => 'https://gitlab.com', })); +jest.mock('@/lib/code-reviews/code-review-ledger', () => ({ + settleCodeReviewLedgerRowOn: (...args: unknown[]) => mockSettleCodeReviewLedgerRowOn(...args), +})); + jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); import { reapStaleCodeReviews, REAP_DEFAULT_BATCH_SIZE } from './reap-stale-reviews'; @@ -160,6 +167,7 @@ beforeEach(() => { mockUpdateCheckRun.mockResolvedValue(undefined); mockSetCommitStatus.mockResolvedValue(undefined); mockResolveGitLabAccessToken.mockResolvedValue('gl-token'); + mockSettleCodeReviewLedgerRowOn.mockResolvedValue(undefined); }); describe('reapStaleCodeReviews', () => { @@ -236,6 +244,33 @@ describe('reapStaleCodeReviews', () => { expect(summary).toMatchObject({ selected: 1, terminalized: 0 }); }); + // The ledger settle runs inside the terminalize transaction, so the event is + // emitted atomically with the terminal claim. + it('settles the ledger row inside the terminalize transaction', async () => { + mockSelectStale.mockResolvedValue([makeReview()]); + + await reapStaleCodeReviews(); + + expect(mockSettleCodeReviewLedgerRowOn).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + reviewId: '00000000-0000-0000-0000-0000000000aa', + status: 'failed', + terminalReason: 'abandoned', + triggerSource: 'webhook', + }) + ); + }); + + // A transient settle failure must propagate so the terminalize rolls back and + // the review stays non-terminal for a later run, instead of losing the event. + it('propagates a settle failure so the terminalize rolls back', async () => { + mockSelectStale.mockResolvedValue([makeReview()]); + mockSettleCodeReviewLedgerRowOn.mockRejectedValue(new Error('settle failed')); + + await expect(reapStaleCodeReviews()).rejects.toThrow('settle failed'); + }); + // A dashboard-only manual review never published anything to the pull // request, so nothing may be created for it now either. it('terminalizes a dashboard-only review without touching the provider', async () => { diff --git a/apps/web/src/lib/code-reviews/reap-stale-reviews.ts b/apps/web/src/lib/code-reviews/reap-stale-reviews.ts index 92fd15b115..bf01be873a 100644 --- a/apps/web/src/lib/code-reviews/reap-stale-reviews.ts +++ b/apps/web/src/lib/code-reviews/reap-stale-reviews.ts @@ -8,6 +8,7 @@ import { captureException } from '@sentry/nextjs'; import { db } from '@/lib/drizzle'; import { APP_URL } from '@/lib/constants'; +import { settleCodeReviewLedgerRowOn } from '@/lib/code-reviews/code-review-ledger'; import { CodeReviewPlatformSchema } from '@/lib/code-reviews/core/schemas'; import { NON_TERMINAL_CODE_REVIEW_STATUSES } from '@/lib/code-reviews/dispatch/dispatch-constants'; import { shouldPublishCodeReviewToProvider } from '@/lib/code-reviews/manual-config'; @@ -115,6 +116,12 @@ async function countRemainingStaleReviews(): Promise { * On a successful claim the review's own non-terminal attempts are closed with * it, the same way the supersede path closes both tables together. Left open, * they would count as in-progress work forever in every attempt-level query. + * + * The ledger settle runs inside this transaction too. A transient settle + * failure throws, rolling back the terminalize so the review stays non-terminal + * and a later reaper run retries both; a missing admit row is not a failure and + * skips. Settling outside the transaction would lose the event permanently, + * because the selection predicate excludes terminal rows from every later run. */ async function terminalizeReview(review: CloudAgentCodeReview): Promise { const now = new Date().toISOString(); @@ -159,6 +166,15 @@ async function terminalizeReview(review: CloudAgentCodeReview): Promise ) ); + // The review is now terminal in this transaction, so settle the ledger row + // atomically with the terminalize. A settle failure rolls back the claim. + await settleCodeReviewLedgerRowOn(tx, { + reviewId: review.id, + status: 'failed', + terminalReason: REAP_TERMINAL_REASON, + triggerSource: review.trigger_source, + }); + return true; }); } @@ -238,7 +254,8 @@ export async function reapStaleCodeReviews( for (const review of stale) { // Claim first. Everything after this is best-effort cleanup, and a provider - // call that fails must not leave the row selectable forever. + // call that fails must not leave the row selectable forever. The ledger + // settle runs inside the claim transaction (see `terminalizeReview`). if (!(await terminalizeReview(review))) continue; summary.terminalized += 1; diff --git a/apps/web/src/lib/code-reviews/review-memory/db.ts b/apps/web/src/lib/code-reviews/review-memory/db.ts index 72adfe3f9e..1e789546b6 100644 --- a/apps/web/src/lib/code-reviews/review-memory/db.ts +++ b/apps/web/src/lib/code-reviews/review-memory/db.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; -import { and, asc, count, desc, eq, gte, inArray, lt, type SQL } from 'drizzle-orm'; +import { and, asc, count, desc, eq, gte, inArray, lt, or, type SQL } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; import { db } from '@/lib/drizzle'; import { @@ -222,6 +223,40 @@ export async function upsertScopeProposal(input: { return inserted; } +export type ReviewMemoryProposalPage = { + proposals: CodeReviewMemoryProposal[]; + nextCursor: string | null; +}; + +const PROPOSAL_CURSOR_SEPARATOR = '|'; +const PROPOSAL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Keyset pagination cursor for `listProposalsPage`. The list orders by +// `updated_at` desc with `id` desc as the deterministic tie-breaker, so the +// cursor encodes the last row's `(updated_at, id)`. `updated_at` is a +// PostgreSQL timestamptz returned as text with microsecond precision (e.g. +// "2026-04-29 01:16:12.945123+00"). The cursor is opaque and only compared +// against the database column, never parsed by a client, so it carries the +// raw value verbatim — normalizing through `new Date(...).toISOString()` would +// truncate microseconds and silently skip rows that share a millisecond. +function encodeProposalCursor(row: CodeReviewMemoryProposal): string { + return `${row.updated_at}${PROPOSAL_CURSOR_SEPARATOR}${row.id}`; +} + +function decodeProposalCursor(cursor: string): { updatedAt: string; id: string } | null { + const separatorIndex = cursor.indexOf(PROPOSAL_CURSOR_SEPARATOR); + if (separatorIndex <= 0) return null; + const updatedAt = cursor.slice(0, separatorIndex); + const id = cursor.slice(separatorIndex + 1); + if (!PROPOSAL_ID_PATTERN.test(id)) return null; + if (Number.isNaN(new Date(updatedAt).getTime())) return null; + return { updatedAt, id }; +} + +// Compatibility: the array-shaped `listProposals` is the deployed contract +// (`origin/main` returns `CodeReviewMemoryProposal[]`); the web panel and +// stale client bundles call it. Keep it, and serve the paginated shape +// through the additive `listProposalsPage`. export async function listProposals(input: { owner: ReviewMemoryOwner; platform: ReviewMemoryPlatform; @@ -230,7 +265,21 @@ export async function listProposals(input: { limit?: number; database?: ReviewMemoryDatabase; }): Promise { + const page = await listProposalsPage(input); + return page.proposals; +} + +export async function listProposalsPage(input: { + owner: ReviewMemoryOwner; + platform: ReviewMemoryPlatform; + repoFullName?: string; + statuses?: ReviewMemoryProposalStatus[]; + limit?: number; + cursor?: string; + database?: ReviewMemoryDatabase; +}): Promise { const database = input.database ?? db; + const limit = Math.min(input.limit ?? 50, 100); const conditions: SQL[] = [ ...proposalOwnerConditions(input.owner), eq(code_review_memory_proposals.platform, input.platform), @@ -241,13 +290,38 @@ export async function listProposals(input: { if (input.statuses && input.statuses.length > 0) { conditions.push(inArray(code_review_memory_proposals.status, input.statuses)); } + if (input.cursor) { + const cursor = decodeProposalCursor(input.cursor); + if (!cursor) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid Review Memory proposal cursor.', + }); + } + const sameTimestamp = and( + eq(code_review_memory_proposals.updated_at, cursor.updatedAt), + lt(code_review_memory_proposals.id, cursor.id) + ); + const cursorPredicate = or( + lt(code_review_memory_proposals.updated_at, cursor.updatedAt), + sameTimestamp + ); + if (cursorPredicate) conditions.push(cursorPredicate); + } - return await database + const rows = await database .select() .from(code_review_memory_proposals) .where(and(...conditions)) - .orderBy(desc(code_review_memory_proposals.updated_at)) - .limit(Math.min(input.limit ?? 50, 100)); + .orderBy(desc(code_review_memory_proposals.updated_at), desc(code_review_memory_proposals.id)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const proposals = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = + hasMore && proposals.length > 0 ? encodeProposalCursor(proposals[proposals.length - 1]) : null; + + return { proposals, nextCursor }; } export async function getProposal(input: { diff --git a/apps/web/src/lib/integrations/oauth/common.ts b/apps/web/src/lib/integrations/oauth/common.ts index 003ed94dd8..f061d086c4 100644 --- a/apps/web/src/lib/integrations/oauth/common.ts +++ b/apps/web/src/lib/integrations/oauth/common.ts @@ -2,6 +2,7 @@ import 'server-only'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { captureException } from '@sentry/nextjs'; +import { TRPCError } from '@trpc/server'; import { APP_URL } from '@/lib/constants'; import { getUserFromAuth } from '@/lib/user/server'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; @@ -11,6 +12,20 @@ import { validateReturnPath } from '@/lib/integrations/validate-return-path'; import type { Owner } from '@/lib/integrations/core/types'; import type { RetainedOAuthPlatform, StandardOAuthPlatform } from '@/lib/integrations/oauth/paths'; +/** + * Maps an organization-access denial to a user-facing OAuth error code. + * `ensureOrganizationAccess` throws the same UNAUTHORIZED code for both + * "no membership" and "insufficient role", so the message distinguishes them. + * Returns null when the error is not an expected access denial. + */ +export function organizationAccessDenialErrorCode(error: unknown): string | null { + if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') return null; + if (error.message === 'You do not have access to this organization') { + return 'organization_access_required'; + } + return 'permission_required'; +} + type AuthenticatedOAuthUser = Parameters[0]['user']; export type ResolveConnectOwnerOptions = { diff --git a/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts index ded1ff3605..c4ac250491 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts @@ -19,8 +19,13 @@ import { verifyGitLabOAuthState, } from '@/lib/integrations/platforms/gitlab/oauth-state'; import { getGitLabOAuthCredentials } from '@/lib/integrations/platforms/gitlab/oauth-credentials'; -import { appendIntegrationOAuthRedirectQuery } from '@/lib/integrations/oauth/common'; +import { + appendIntegrationOAuthRedirectQuery, + organizationAccessDenialErrorCode, +} from '@/lib/integrations/oauth/common'; import { storeGitLabOAuthIntegration } from '@/lib/integrations/platforms/gitlab/oauth-integration-writer'; +import { getIntegrationForOrganization } from '@/lib/integrations/db/platform-integrations'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; function buildGitLabRedirectPath( state: Pick | null | undefined, @@ -99,7 +104,14 @@ export async function handleGitLabOAuthCallback(request: NextRequest) { const normalizedInstanceUrl = normalizeGitLabInstanceUrl(instanceUrl); if (owner.type === 'org') { - await ensureOrganizationAccess({ user }, owner.id); + // Replacing an existing org GitLab integration is a billing-scoped action; + // a first-time connect keeps member-level access. + const existingIntegration = await getIntegrationForOrganization(owner.id, PLATFORM.GITLAB); + await ensureOrganizationAccess( + { user }, + owner.id, + existingIntegration ? ORGANIZATION_BILLING_ROLES : undefined + ); } else if (user.id !== owner.id) { return NextResponse.redirect(new URL('/integrations?error=unauthorized', APP_URL)); } @@ -189,17 +201,20 @@ export async function handleGitLabOAuthCallback(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const state = searchParams.get('state'); - captureException(error, { - tags: { - endpoint: 'gitlab/callback', - source: 'gitlab_oauth', - }, - extra: gitLabOAuthSentryContext(searchParams), - }); + const denialCode = organizationAccessDenialErrorCode(error); + if (!denialCode) { + captureException(error, { + tags: { + endpoint: 'gitlab/callback', + source: 'gitlab_oauth', + }, + extra: gitLabOAuthSentryContext(searchParams), + }); + } const redirectPath = buildGitLabRedirectPath( verifyGitLabOAuthState(state), - 'error=connection_failed' + denialCode ? `error=${denialCode}` : 'error=connection_failed' ); return NextResponse.redirect(new URL(redirectPath, APP_URL)); } diff --git a/apps/web/src/lib/integrations/oauth/platforms/gitlab-connect.ts b/apps/web/src/lib/integrations/oauth/platforms/gitlab-connect.ts index cb957bb8a1..4d6efa4571 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/gitlab-connect.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/gitlab-connect.ts @@ -15,8 +15,11 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; import { validateReturnPath } from '@/lib/integrations/validate-return-path'; import { buildIntegrationOAuthConnectErrorPath, + organizationAccessDenialErrorCode, redirectToSignInForOAuthConnect, } from '@/lib/integrations/oauth/common'; +import { getIntegrationForOrganization } from '@/lib/integrations/db/platform-integrations'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import type { Owner } from '@/lib/integrations/core/types'; type AuthenticatedOAuthUser = Parameters[0]['user']; @@ -78,16 +81,23 @@ export async function handleGitLabOAuthConnect(request: NextRequest) { } catch (error) { console.error('Error initiating GitLab OAuth:', error); - captureException(error, { - tags: { - endpoint: 'gitlab/connect', - source: 'gitlab_oauth', - }, - }); + const denialCode = organizationAccessDenialErrorCode(error); + if (!denialCode) { + captureException(error, { + tags: { + endpoint: 'gitlab/connect', + source: 'gitlab_oauth', + }, + }); + } return NextResponse.redirect( new URL( - buildIntegrationOAuthConnectErrorPath(PLATFORM.GITLAB, organizationId, 'oauth_init_failed'), + buildIntegrationOAuthConnectErrorPath( + PLATFORM.GITLAB, + organizationId, + denialCode ?? 'oauth_init_failed' + ), request.url ) ); @@ -129,16 +139,23 @@ export async function handleGitLabOAuthConnectPost(request: NextRequest): Promis } catch (error) { console.error('Error initiating GitLab OAuth:', error); - captureException(error, { - tags: { - endpoint: 'gitlab/connect', - source: 'gitlab_oauth', - }, - extra: { - organizationId, - hasCustomCredentials: Boolean(clientId && clientSecret), - }, - }); + const denialCode = organizationAccessDenialErrorCode(error); + if (!denialCode) { + captureException(error, { + tags: { + endpoint: 'gitlab/connect', + source: 'gitlab_oauth', + }, + extra: { + organizationId, + hasCustomCredentials: Boolean(clientId && clientSecret), + }, + }); + } + + if (denialCode) { + return NextResponse.json({ error: denialCode }, { status: 403 }); + } return NextResponse.json({ error: 'oauth_init_failed' }, { status: 500 }); } @@ -195,6 +212,13 @@ async function resolveGitLabOAuthOwner( return { type: 'user', id: user.id }; } - await ensureOrganizationAccess({ user }, organizationId); + // Replacing an existing org GitLab integration is a billing-scoped action; + // a first-time connect keeps member-level access. + const existingIntegration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB); + await ensureOrganizationAccess( + { user }, + organizationId, + existingIntegration ? ORGANIZATION_BILLING_ROLES : undefined + ); return { type: 'org', id: organizationId }; } diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/manual-code-review-trigger.ts b/apps/web/src/lib/integrations/platforms/bitbucket/manual-code-review-trigger.ts index de1218ca2d..7f75af5f8d 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/manual-code-review-trigger.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/manual-code-review-trigger.ts @@ -8,6 +8,7 @@ import { db, type DrizzleTransaction } from '@/lib/drizzle'; import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; import { getUnblockedBotUserForOrg } from '@/lib/bot-users/bot-user-service'; import { + admitCodeReviewLedgerRow, bitbucketCodeReviewerLifecycleLockKey, cancelSupersededReviewsForPRInTransaction, createCodeReviewIfAbsentInTransaction, @@ -16,6 +17,7 @@ import { type ReviewScope, } from '@/lib/code-reviews/db/code-reviews'; import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client'; +import { settleCodeReviewLedgerRow } from '@/lib/code-reviews/code-review-ledger'; import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews'; import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; import { getBitbucketCodeReviewerReadiness } from './workspace-access-token-repository-cache'; @@ -260,6 +262,17 @@ async function interruptCancelledReviews(cancelledReviews: CancelledReviewRow[]) ); } +async function settleCancelledReviews(cancelledReviews: CancelledReviewRow[]): Promise { + for (const review of cancelledReviews) { + await settleCodeReviewLedgerRow({ + reviewId: review.id, + status: 'cancelled', + terminalReason: 'superseded', + triggerSource: review.triggerSource, + }); + } +} + export async function triggerManualBitbucketCodeReview(input: { organizationId: string; pullRequestUrl: string; @@ -482,7 +495,16 @@ export async function triggerManualBitbucketCodeReview(input: { } await interruptCancelledReviews(transactionResult.cancelledReviews); + await settleCancelledReviews(transactionResult.cancelledReviews); if (transactionResult.created) { + // Best-effort ledger admission (P1-A-07c): the review row already + // committed, so a ledger write failure must not fail the manual trigger. + await admitCodeReviewLedgerRow({ + reviewId: transactionResult.reviewId, + userId: ownerWithBot.userId, + orgId: ownerWithBot.type === 'org' ? ownerWithBot.id : null, + triggerSource: 'manual', + }); try { await tryDispatchPendingReviews(ownerWithBot); } catch { diff --git a/apps/web/src/lib/notifications-worker-client.ts b/apps/web/src/lib/notifications-worker-client.ts index d043cc4757..d3623c8812 100644 --- a/apps/web/src/lib/notifications-worker-client.ts +++ b/apps/web/src/lib/notifications-worker-client.ts @@ -4,10 +4,14 @@ import { captureException } from '@sentry/nextjs'; import type { InternalDispatchLowBalanceRequest, InternalDispatchSecurityFindingRequest, + InternalDispatchSecurityLifecycleRequest, } from '@kilocode/notifications'; import { INTERNAL_API_SECRET, NOTIFICATIONS_WORKER_URL } from '@/lib/config.server'; -type DispatchBody = InternalDispatchLowBalanceRequest | InternalDispatchSecurityFindingRequest; +type DispatchBody = + | InternalDispatchLowBalanceRequest + | InternalDispatchSecurityFindingRequest + | InternalDispatchSecurityLifecycleRequest; /** * Best-effort POST to the notifications worker internal dispatch endpoint. @@ -70,3 +74,9 @@ export async function dispatchSecurityFindingPush( ): Promise { await dispatchInternal({ kind: 'security_finding', ...input }); } + +export async function dispatchSecurityLifecyclePush( + input: Omit +): Promise { + await dispatchInternal({ kind: 'security_lifecycle', ...input }); +} diff --git a/apps/web/src/lib/organizations/organization-types.ts b/apps/web/src/lib/organizations/organization-types.ts index de271b4c75..a42519403c 100644 --- a/apps/web/src/lib/organizations/organization-types.ts +++ b/apps/web/src/lib/organizations/organization-types.ts @@ -180,18 +180,46 @@ export const PublicOrganizationMemberSchema = z.discriminatedUnion('status', [ export const PublicOrganizationMembersSchema = z.array(PublicOrganizationMemberSchema); +export const ChildOrganizationSummarySchema = z.object({ + id: z.string(), + name: z.string(), +}); + +export const ChildOrganizationMembershipSchema = ChildOrganizationSummarySchema.extend({ + role: OrganizationRoleSchema, +}); + +// Member-visible variants returned by `organizations.withMembers` for `member` +// callers. The member response omits the Stripe customer id, the invite +// secret, and per-member daily usage, so the type reflects the stripped shape +// instead of casting back to the admin superset. +export const MemberOrganizationSchema = OrganizationSchema.omit({ + stripe_customer_id: true, +}); + +export const MemberInvitedOrganizationMemberSchema = InvitedOrganizationMemberSchema.omit({ + inviteToken: true, + inviteUrl: true, + currentDailyUsageUsd: true, +}); + +export const MemberActiveOrganizationMemberSchema = ActiveOrganizationMemberSchema.omit({ + currentDailyUsageUsd: true, +}).extend({ + childOrganizationMemberships: z.array(ChildOrganizationMembershipSchema).optional(), +}); + +export const MemberOrganizationMemberSchema = z.discriminatedUnion('status', [ + MemberActiveOrganizationMemberSchema, + MemberInvitedOrganizationMemberSchema, +]); + export const OrganizationWithMembersSchema = OrganizationSchema.extend({ members: z.array(OrganizationMemberSchema), }); -export type ChildOrganizationSummary = { - id: string; - name: string; -}; - -export type ChildOrganizationMembership = ChildOrganizationSummary & { - role: OrganizationRole; -}; +export type ChildOrganizationSummary = z.infer; +export type ChildOrganizationMembership = z.infer; export type OrganizationSsoPolicyView = { required: boolean; @@ -201,12 +229,28 @@ export type OrganizationSsoPolicyView = { }; export type OrganizationWithMembers = z.infer & { - callerRole: OrganizationRole; + callerRole: Exclude; members: OrganizationMember[]; childOrganizations: ChildOrganizationSummary[]; effectiveSsoPolicy: OrganizationSsoPolicyView; }; +export type MemberOrganizationMember = z.infer; + +export type MemberOrganizationWithMembers = z.infer & { + callerRole: 'member'; + members: MemberOrganizationMember[]; + childOrganizations: ChildOrganizationSummary[]; + effectiveSsoPolicy: OrganizationSsoPolicyView; +}; + +export type OrganizationWithMembersResponse = + | OrganizationWithMembers + | MemberOrganizationWithMembers; + +/** A member row from `withMembers`, in either the admin or member variant. */ +export type OrganizationMemberResponse = OrganizationMember | MemberOrganizationMember; + export type AcceptInviteResult = Result< { invitation: typeof organization_invitations.$inferSelect; diff --git a/apps/web/src/lib/organizations/organizations.test.ts b/apps/web/src/lib/organizations/organizations.test.ts index 746786439a..36afa6a98c 100644 --- a/apps/web/src/lib/organizations/organizations.test.ts +++ b/apps/web/src/lib/organizations/organizations.test.ts @@ -135,6 +135,29 @@ describe('Organizations', () => { expect(result).toEqual([]); }); + + test('returns exactly the documented UserOrganizationWithSeats key set with no extra org fields', async () => { + const user = await insertTestUser(); + await createOrganization('Key Set Org', user.id); + + const result = await getUserOrganizationsWithSeats(user.id); + + expect(result).toHaveLength(1); + expect(Object.keys(result[0]).sort()).toEqual( + [ + 'balance', + 'created_at', + 'memberCount', + 'organizationId', + 'organizationName', + 'plan', + 'requireSeats', + 'role', + 'seatCount', + ].sort() + ); + expect(Object.keys(result[0].seatCount).sort()).toEqual(['total', 'used'].sort()); + }); }); describe('getProfileOrganizations', () => { diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index a7034845e0..79b3f7f132 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -70,8 +70,15 @@ export async function getUserOrganizationsWithSeats( ): Promise { const results = await db .select({ - organization: organizations, - membership: organization_memberships, + organizationId: organizations.id, + organizationName: organizations.name, + role: organization_memberships.role, + totalMicrodollarsAcquired: organizations.total_microdollars_acquired, + microdollarsUsed: organizations.microdollars_used, + requireSeats: organizations.require_seats, + plan: organizations.plan, + createdAt: organizations.created_at, + seatCountTotal: organizations.seat_count, total_member_count: sql`( SELECT COUNT(*)::int FROM ( SELECT 1 FROM ${organization_memberships} om @@ -98,18 +105,17 @@ export async function getUserOrganizationsWithSeats( ); return results.map(result => ({ - organizationName: result.organization.name, - organizationId: result.organization.id, - role: result.membership.role, + organizationName: result.organizationName, + organizationId: result.organizationId, + role: result.role, memberCount: result.total_member_count, - balance: - result.organization.total_microdollars_acquired - result.organization.microdollars_used, - requireSeats: result.organization.require_seats, - plan: result.organization.plan, - created_at: result.organization.created_at, + balance: result.totalMicrodollarsAcquired - result.microdollarsUsed, + requireSeats: result.requireSeats, + plan: result.plan, + created_at: result.createdAt, seatCount: { used: result.total_member_count, - total: result.organization.seat_count, + total: result.seatCountTotal, }, })); } diff --git a/apps/web/src/lib/security-agent/command-type-drift.test.ts b/apps/web/src/lib/security-agent/command-type-drift.test.ts new file mode 100644 index 0000000000..a558e529ef --- /dev/null +++ b/apps/web/src/lib/security-agent/command-type-drift.test.ts @@ -0,0 +1,24 @@ +import { + SECURITY_COMMAND_TYPES, + type SecurityCommandType, +} from '@kilocode/app-shared/security-agent'; +import type { SecurityAgentCommandType } from '@kilocode/db/schema'; + +// Compile-time assertion: the db command-type union and the shared tuple must +// stay identical. A tuple edit without a matching db edit (or vice versa) fails +// typecheck here. +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; +type Expect = T; +type _CommandTypesMatch = Expect>; + +describe('security command type authority', () => { + it('keeps the shared tuple exactly equal to the four command types', () => { + expect(SECURITY_COMMAND_TYPES).toEqual([ + 'sync', + 'dismiss_finding', + 'start_analysis', + 'apply_auto_remediation', + ]); + }); +}); diff --git a/apps/web/src/lib/security-agent/core/constants.test.ts b/apps/web/src/lib/security-agent/core/constants.test.ts index fe1412fb2e..b7db2ce703 100644 --- a/apps/web/src/lib/security-agent/core/constants.test.ts +++ b/apps/web/src/lib/security-agent/core/constants.test.ts @@ -31,6 +31,17 @@ describe('security agent config', () => { expect(parseSecurityAgentConfig({}).sla_notifications_enabled).toBe(false); }); + it('pins the high-confidence automation defaults', () => { + const config = parseSecurityAgentConfig({}); + expect(config.auto_dismiss_confidence_threshold).toBe('high'); + expect(config.auto_analysis_min_severity).toBe('high'); + expect(config.auto_remediation_min_severity).toBe('high'); + }); + + it('defaults auto-remediation approval to not required for legacy config', () => { + expect(parseSecurityAgentConfig({}).auto_remediation_require_approval).toBe(false); + }); + it('tolerates malformed notification fields during general config reads', () => { expect(() => parseSecurityAgentConfig({ diff --git a/apps/web/src/lib/security-agent/core/constants.ts b/apps/web/src/lib/security-agent/core/constants.ts index 024b6e28ba..7b839a9e8c 100644 --- a/apps/web/src/lib/security-agent/core/constants.ts +++ b/apps/web/src/lib/security-agent/core/constants.ts @@ -34,6 +34,7 @@ export const DEFAULT_SECURITY_AGENT_CONFIG: SecurityAgentConfig = { auto_remediation_enabled: false, auto_remediation_min_severity: 'high', auto_remediation_include_existing: false, + auto_remediation_require_approval: false, auto_remediation_enabled_at: null, remediation_model_slug: DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, ...DEFAULT_SECURITY_NOTIFICATION_POLICY, diff --git a/apps/web/src/lib/security-agent/core/schemas.ts b/apps/web/src/lib/security-agent/core/schemas.ts index 9479a206cc..0125a3ea37 100644 --- a/apps/web/src/lib/security-agent/core/schemas.ts +++ b/apps/web/src/lib/security-agent/core/schemas.ts @@ -67,6 +67,7 @@ export const SaveSecurityConfigInputSchema = z.object({ autoRemediationEnabled: z.boolean().optional(), autoRemediationMinSeverity: AutoRemediationMinSeveritySchema.optional(), autoRemediationIncludeExisting: z.boolean().optional(), + autoRemediationRequireApproval: z.boolean().optional(), remediationModelSlug: z.string().optional(), slaNotificationsEnabled: z.boolean().optional(), slaNotificationMinSeverity: NotificationMinSeveritySchema.optional(), @@ -207,6 +208,10 @@ export const GetCommandStatusInputSchema = z.object({ commandId: z.string().uuid(), }); +export const GetCommandStatusesInputSchema = z.object({ + commandIds: z.array(z.string().uuid()).min(1).max(100), +}); + export const DeleteFindingsByRepoInputSchema = z.object({ repoFullName: z.string().min(1), }); @@ -237,5 +242,6 @@ export type RetryRemediationInput = z.infer; export type CancelRemediationInput = z.infer; export type GetAnalysisInput = z.infer; export type GetCommandStatusInput = z.infer; +export type GetCommandStatusesInput = z.infer; export type DeleteFindingsByRepoInput = z.infer; export type GetDashboardStatsInput = z.infer; diff --git a/apps/web/src/lib/security-agent/core/types.ts b/apps/web/src/lib/security-agent/core/types.ts index 20dcc461e5..86a878632b 100644 --- a/apps/web/src/lib/security-agent/core/types.ts +++ b/apps/web/src/lib/security-agent/core/types.ts @@ -73,6 +73,7 @@ export const SecurityAgentConfigSchema = z auto_remediation_enabled: z.boolean().default(false), auto_remediation_min_severity: z.enum(['critical', 'high', 'medium', 'all']).default('high'), auto_remediation_include_existing: z.boolean().default(false), + auto_remediation_require_approval: z.boolean().default(false), auto_remediation_enabled_at: z.string().nullable().default(null), remediation_model_slug: z.string().optional(), sla_notifications_enabled: z diff --git a/apps/web/src/lib/security-agent/db/security-commands.test.ts b/apps/web/src/lib/security-agent/db/security-commands.test.ts index 54ad07d22a..953260e0de 100644 --- a/apps/web/src/lib/security-agent/db/security-commands.test.ts +++ b/apps/web/src/lib/security-agent/db/security-commands.test.ts @@ -4,6 +4,7 @@ import { createSecurityAgentCommand, deleteRetainedSecurityAgentCommands, getSecurityAgentCommandForOwner, + getSecurityAgentCommandsForOwner, getSecurityAgentRepositorySyncState, listActiveSecurityAgentCommandsForOwner, markSecurityAgentCommandRetriesExhausted, @@ -230,6 +231,30 @@ describe('Security Agent command ledger', () => { ).resolves.toBeGreaterThanOrEqual(1); }); + it('fetches a batch of commands scoped to the owner and omits unknown ids', async () => { + const owner = await insertTestUser(); + const otherOwner = await insertTestUser(); + const owned = await createSecurityAgentCommand(db, { + commandType: 'sync', + origin: 'manual', + owner: { type: 'user', id: owner.id }, + }); + const foreign = await createSecurityAgentCommand(db, { + commandType: 'sync', + origin: 'manual', + owner: { type: 'user', id: otherOwner.id }, + }); + + const unknownId = '00000000-0000-4000-8000-000000000000'; + const result = await getSecurityAgentCommandsForOwner(db, { type: 'user', id: owner.id }, [ + owned.id, + foreign.id, + unknownId, + ]); + + expect(result.map(command => command.id)).toEqual([owned.id]); + }); + it('lists active commands and clean-repository freshness for only requested owner', async () => { const owner = await insertTestUser(); const otherOwner = await insertTestUser(); diff --git a/apps/web/src/lib/security-agent/db/security-commands.ts b/apps/web/src/lib/security-agent/db/security-commands.ts index 0953aec478..039e955813 100644 --- a/apps/web/src/lib/security-agent/db/security-commands.ts +++ b/apps/web/src/lib/security-agent/db/security-commands.ts @@ -2,11 +2,13 @@ import { db } from '@/lib/drizzle'; import { createSecurityAgentCommand, getSecurityAgentCommandForOwner, + getSecurityAgentCommandsForOwner, listActiveSecurityAgentCommandsForOwner, markSecurityAgentCommandQueueAdmissionFailed, type SecurityAgentCommandOwner, } from '@kilocode/db'; import type { SecurityAgentCommand } from '@kilocode/db/schema'; +import type { SecurityCommandType } from '@kilocode/app-shared/security-agent'; import type { SecurityReviewOwner } from '../core/types'; function toCommandOwner(owner: SecurityReviewOwner): SecurityAgentCommandOwner { @@ -51,6 +53,14 @@ export async function getSecurityAgentCommandStatus( return command ? serializeSecurityAgentCommand(command) : null; } +export async function getSecurityAgentCommandStatuses( + owner: SecurityReviewOwner, + commandIds: string[] +): Promise { + const commands = await getSecurityAgentCommandsForOwner(db, toCommandOwner(owner), commandIds); + return commands.map(serializeSecurityAgentCommand); +} + export async function listActiveSecurityAgentCommands( owner: SecurityReviewOwner ): Promise { @@ -60,7 +70,7 @@ export async function listActiveSecurityAgentCommands( export async function createApplyAutoRemediationCommand(owner: SecurityReviewOwner) { const command = await createSecurityAgentCommand(db, { - commandType: 'apply_auto_remediation', + commandType: 'apply_auto_remediation' satisfies SecurityCommandType, origin: 'settings_include_existing', owner: toCommandOwner(owner), }); diff --git a/apps/web/src/lib/security-agent/db/security-config.test.ts b/apps/web/src/lib/security-agent/db/security-config.test.ts index 3ca435fd49..213800ec84 100644 --- a/apps/web/src/lib/security-agent/db/security-config.test.ts +++ b/apps/web/src/lib/security-agent/db/security-config.test.ts @@ -1,6 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { db } from '@/lib/drizzle'; -import { agent_configs, type User } from '@kilocode/db/schema'; +import { agent_configs, security_agent_commands, type User } from '@kilocode/db/schema'; import { eq, sql } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; @@ -28,6 +28,7 @@ beforeEach(async () => { jest.clearAllMocks(); user = await insertTestUser(); await db.delete(agent_configs).where(sql`true`); + await db.delete(security_agent_commands).where(sql`true`); }); function owner() { @@ -154,4 +155,42 @@ describe('saveSecurityAgentConfigWithRevision', () => { expect(mockResetOwnerAutoAnalysisEnabledAt).toHaveBeenCalledTimes(1); expect(mockResetOwnerAutoAnalysisEnabledAt.mock.calls[0]?.[1]).toBe(enqueueTx); }); + + it('skips the include-existing remediation command when approval is required', async () => { + const outcome = await saveSecurityAgentConfigWithRevision({ + owner: owner(), + config: { auto_remediation_enabled: true, auto_remediation_require_approval: true }, + createdBy: user.id, + expectedRevision: null, + enqueueRemediation: { owner: { userId: user.id } }, + }); + + expect(outcome.existingRemediationCommandId).toBeUndefined(); + const commands = await db + .select({ id: security_agent_commands.id }) + .from(security_agent_commands) + .where(eq(security_agent_commands.owned_by_user_id, user.id)); + expect(commands).toHaveLength(0); + }); + + it('creates the include-existing remediation command when approval is not required', async () => { + const outcome = await saveSecurityAgentConfigWithRevision({ + owner: owner(), + config: { auto_remediation_enabled: true, auto_remediation_require_approval: false }, + createdBy: user.id, + expectedRevision: null, + enqueueRemediation: { owner: { userId: user.id } }, + }); + + expect(outcome.existingRemediationCommandId).toBeDefined(); + const commands = await db + .select({ + id: security_agent_commands.id, + command_type: security_agent_commands.command_type, + }) + .from(security_agent_commands) + .where(eq(security_agent_commands.owned_by_user_id, user.id)); + expect(commands).toHaveLength(1); + expect(commands[0]?.command_type).toBe('apply_auto_remediation'); + }); }); diff --git a/apps/web/src/lib/security-agent/db/security-config.ts b/apps/web/src/lib/security-agent/db/security-config.ts index e1547a61ee..bca00dbb41 100644 --- a/apps/web/src/lib/security-agent/db/security-config.ts +++ b/apps/web/src/lib/security-agent/db/security-config.ts @@ -7,6 +7,7 @@ import type { Owner } from '@/lib/code-reviews/core'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; import { createSecurityAgentCommand, type SecurityAgentCommandOwner } from '@kilocode/db'; import { agent_configs } from '@kilocode/db/schema'; +import type { SecurityCommandType } from '@kilocode/app-shared/security-agent'; import { TRPCError } from '@trpc/server'; import { and, eq } from 'drizzle-orm'; import { @@ -282,9 +283,12 @@ export async function saveSecurityAgentConfigWithRevision(params: { } let existingRemediationCommandId: string | undefined; - if (params.enqueueRemediation) { + // Approval-required mode skips the include-existing bulk command: the + // worker policy would reject every candidate with `approval_required`, and + // the manual startRemediation path is the approval flow. + if (params.enqueueRemediation && !fullConfig.auto_remediation_require_approval) { const command = await createSecurityAgentCommand(tx, { - commandType: 'apply_auto_remediation', + commandType: 'apply_auto_remediation' satisfies SecurityCommandType, origin: 'settings_include_existing', owner: toCommandOwner(params.enqueueRemediation.owner), }); diff --git a/apps/web/src/lib/security-agent/db/security-remediation.ts b/apps/web/src/lib/security-agent/db/security-remediation.ts index f60f5e88e3..94e51970c9 100644 --- a/apps/web/src/lib/security-agent/db/security-remediation.ts +++ b/apps/web/src/lib/security-agent/db/security-remediation.ts @@ -250,6 +250,7 @@ function toPolicyConfig(config: SecurityAgentConfig): SecurityRemediationConfig auto_remediation_enabled: config.auto_remediation_enabled, auto_remediation_min_severity: config.auto_remediation_min_severity, auto_remediation_include_existing: config.auto_remediation_include_existing, + auto_remediation_require_approval: config.auto_remediation_require_approval, auto_remediation_enabled_at: config.auto_remediation_enabled_at, }; } diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts index 0459d44744..3b4f39e481 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts @@ -6,9 +6,16 @@ import type * as manualDismissClientModule from '../services/manual-dismiss-clie import type * as manualAnalysisClientModule from '../services/manual-analysis-client'; import type * as manualRemediationClientModule from '../services/manual-remediation-client'; import { randomUUID } from 'crypto'; -import { sql } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { db } from '@/lib/drizzle'; -import { operation_ledgers, type OperationLedgerRow } from '@kilocode/db/schema'; +import { + operation_ledgers, + organizations, + security_audit_log, + type OperationLedgerRow, +} from '@kilocode/db/schema'; +import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import type { SecurityFindingWithRemediation } from '../db/security-remediation'; const commandId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const mockSubmitManualSecuritySync = jest.fn() as jest.MockedFunction< @@ -30,6 +37,7 @@ const mockSubmitRemediationCancellation = jest.fn() as jest.MockedFunction< typeof manualRemediationClientModule.submitRemediationCancellation >; const mockGetSecurityFindingById = jest.fn<() => Promise>(); +const mockListSecurityFindings = jest.fn<() => Promise>(); const mockCanStartAnalysis = jest.fn<(owner: unknown) => Promise>(); const mockEnqueueBacklogFindings = jest.fn<() => Promise>(); const mockGetSecurityAgentConfigWithStatus = jest.fn<() => Promise>(); @@ -68,6 +76,7 @@ const mockMarkReconcilePending = jest.fn<(...args: unknown[]) => Promise Promise>(); const mockSettleOperation = jest.fn<(...args: unknown[]) => Promise>(); const mockGetSecurityAgentCommandStatus = jest.fn<(...args: unknown[]) => Promise>(); +const mockGetSecurityAgentCommandStatuses = jest.fn<(...args: unknown[]) => Promise>(); jest.mock('../services/manual-sync-client', () => ({ submitManualSecuritySync: mockSubmitManualSecuritySync, @@ -124,7 +133,7 @@ jest.mock('../db/security-config', () => ({ setSecurityAgentEnabled: mockSetSecurityAgentEnabled, })); jest.mock('../db/security-findings', () => ({ - listSecurityFindings: jest.fn(), + listSecurityFindings: mockListSecurityFindings, getSecurityFindingById: mockGetSecurityFindingById, getSecurityFindingsSummary: jest.fn(), getLastSyncTime: jest.fn(), @@ -138,6 +147,7 @@ jest.mock('../db/security-remediation', () => ({ })); jest.mock('../db/security-commands', () => ({ getSecurityAgentCommandStatus: mockGetSecurityAgentCommandStatus, + getSecurityAgentCommandStatuses: mockGetSecurityAgentCommandStatuses, listActiveSecurityAgentCommands: jest.fn(), })); jest.mock('../db/dashboard-stats', () => ({ getDashboardStats: jest.fn() })); @@ -383,6 +393,44 @@ describe('getConfig', () => { isEnabled: false, }); }); + + it('pins the high-confidence automation defaults for legacy configs', async () => { + mockGetSecurityAgentConfigWithStatus.mockResolvedValue({ + isEnabled: true, + storedConfig: {}, + config: { + sla_critical_days: 15, + sla_high_days: 30, + sla_medium_days: 45, + sla_low_days: 90, + sla_enabled: true, + auto_sync_enabled: true, + repository_selection_mode: 'selected', + selected_repository_ids: [], + model_slug: 'analysis-model', + analysis_mode: 'auto', + auto_dismiss_enabled: false, + auto_analysis_enabled: false, + auto_analysis_include_existing: false, + auto_remediation_enabled: false, + auto_remediation_include_existing: false, + auto_remediation_enabled_at: null, + remediation_model_slug: 'remediation-model', + sla_notifications_enabled: false, + sla_notification_min_severity: 'high', + sla_notification_warning_days: 3, + new_finding_notifications_enabled: false, + new_finding_notification_min_severity: 'high', + }, + }); + + await expect(createHandlers().getConfig({ ctx: context, input: {} })).resolves.toMatchObject({ + autoDismissConfidenceThreshold: 'high', + autoAnalysisMinSeverity: 'high', + autoRemediationMinSeverity: 'high', + autoRemediationRequireApproval: true, + }); + }); }); describe('setEnabled', () => { @@ -499,6 +547,63 @@ describe('saveConfig', () => { }) ).rejects.toMatchObject({ code: 'CONFLICT' }); }); + + it('maps autoRemediationRequireApproval to the snake_case config field', async () => { + mockSaveSecurityAgentConfigWithRevision.mockResolvedValue({ newRevision: 2 }); + + await createHandlers().saveConfig.handler({ + ctx: context, + input: { expectedRevision: 1, autoRemediationRequireApproval: false }, + }); + + expect(mockSaveSecurityAgentConfigWithRevision).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ auto_remediation_require_approval: false }), + }) + ); + }); + + it('enqueues the include-existing remediation backlog when approval is turned off', async () => { + mockSaveSecurityAgentConfigWithRevision.mockResolvedValue({ newRevision: 2 }); + mockGetSecurityAgentConfigWithStatus.mockResolvedValue({ + isEnabled: true, + storedConfig: {}, + config: { + auto_remediation_enabled: true, + auto_remediation_include_existing: true, + auto_remediation_require_approval: true, + }, + }); + + // First save: auto-remediation and include-existing are already on, with + // approval required. No bulk command is enqueued while approval is on. + await createHandlers().saveConfig.handler({ + ctx: context, + input: { + expectedRevision: 1, + autoRemediationEnabled: true, + autoRemediationIncludeExisting: true, + autoRemediationRequireApproval: true, + }, + }); + expect(mockSaveSecurityAgentConfigWithRevision).toHaveBeenLastCalledWith( + expect.objectContaining({ enqueueRemediation: undefined }) + ); + + // Second save: approval turns off. The include-existing backlog is enqueued. + await createHandlers().saveConfig.handler({ + ctx: context, + input: { expectedRevision: 2, autoRemediationRequireApproval: false }, + }); + + expect(mockSaveSecurityAgentConfigWithRevision).toHaveBeenLastCalledWith( + expect.objectContaining({ + enqueueRemediation: { + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + }, + }) + ); + }); }); describe('autoDismissEligible', () => { @@ -601,6 +706,136 @@ describe('getAnalysis', () => { }); }); +describe('getAnalysis remediation timeline', () => { + const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const orgId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + + const finding = { + id: findingId, + status: 'open', + ignored_reason: null, + ignored_by: null, + fixed_at: null, + updated_at: '2026-06-17T11:45:00.000Z', + analysis_status: 'completed', + analysis_started_at: '2026-06-17T11:40:00.000Z', + analysis_completed_at: '2026-06-17T11:44:59.000Z', + analysis_error: null, + analysis: { analyzedAt: '2026-06-17T11:44:59.000Z' }, + session_id: 'session-123', + cli_session_id: 'cli-session-123', + }; + const decoratedFinding = { + ...finding, + remediationSummary: null, + remediationCapability: { + canStart: false, + startReason: 'finding_not_open', + canRetry: false, + retryReason: 'finding_not_open', + canCancel: false, + cancelAttemptId: null, + }, + }; + + async function insertAuditRow( + action: SecurityAuditLogAction, + occurredAt: string | null, + createdAt: string + ) { + await db.insert(security_audit_log).values({ + owned_by_organization_id: orgId, + owned_by_user_id: null, + action, + resource_type: 'security_finding', + resource_id: findingId, + finding_id: findingId, + occurred_at: occurredAt, + created_at: createdAt, + }); + } + + beforeEach(async () => { + await db + .insert(organizations) + .values({ id: orgId, name: 'Timeline Test Org' }) + .onConflictDoNothing(); + await db.delete(security_audit_log).where(eq(security_audit_log.finding_id, findingId)); + mockGetSecurityFindingById.mockResolvedValue(finding); + mockDecorateFindingWithRemediation.mockResolvedValue(decoratedFinding); + }); + + afterAll(async () => { + await db.delete(security_audit_log).where(eq(security_audit_log.finding_id, findingId)); + await db.delete(organizations).where(eq(organizations.id, orgId)); + }); + + it('orders remediation events ascending by occurred_at with created_at fallback and normalizes to UTC ISO', async () => { + await insertAuditRow( + SecurityAuditLogAction.RemediationQueued, + '2026-04-29 01:16:12.945+00', + '2026-04-29 01:16:12.945+00' + ); + await insertAuditRow( + SecurityAuditLogAction.RemediationPrOpened, + null, + '2026-04-29 02:00:00.000+00' + ); + await insertAuditRow( + SecurityAuditLogAction.RemediationFailed, + '2026-04-29 01:30:00.000+00', + '2026-04-29 01:30:00.000+00' + ); + + const result = await createHandlers().getAnalysis.handler({ + ctx: context, + input: { findingId }, + }); + + expect(result.remediationTimeline).toEqual([ + { action: 'security.remediation.queued', occurredAt: '2026-04-29T01:16:12.945Z' }, + { action: 'security.remediation.failed', occurredAt: '2026-04-29T01:30:00.000Z' }, + { action: 'security.remediation.pr_opened', occurredAt: '2026-04-29T02:00:00.000Z' }, + ]); + }); + + it('returns only remediation actions, not finding lifecycle actions', async () => { + await insertAuditRow( + SecurityAuditLogAction.FindingCreated, + '2026-04-29 01:00:00.000+00', + '2026-04-29 01:00:00.000+00' + ); + await insertAuditRow( + SecurityAuditLogAction.RemediationQueued, + '2026-04-29 01:10:00.000+00', + '2026-04-29 01:10:00.000+00' + ); + + const result = await createHandlers().getAnalysis.handler({ + ctx: context, + input: { findingId }, + }); + + expect(result.remediationTimeline.map(event => event.action)).toEqual([ + 'security.remediation.queued', + ]); + }); + + it('returns an empty timeline when no remediation audit rows exist, keeping the original shape valid', async () => { + const result = await createHandlers().getAnalysis.handler({ + ctx: context, + input: { findingId }, + }); + + expect(result.remediationTimeline).toEqual([]); + expect(result).toMatchObject({ + findingState: { status: 'open' }, + status: 'completed', + remediationAttempts: [], + }); + }); +}); + describe('queue-backed handlers', () => { it('returns sync command correlation', async () => { mockSubmitManualSecuritySync.mockResolvedValue({ @@ -642,6 +877,15 @@ describe('queue-backed handlers', () => { mockGetSecurityFindingById.mockResolvedValue({ id: 'finding-id' }); mockCanStartAnalysis.mockResolvedValue({ allowed: true, currentCount: 0, limit: 3 }); mockSubmitManualAnalysisStart.mockResolvedValue({ queued: true, commandId }); + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: { + id: 'ledger-row-id', + intent: 'start_analysis', + resource_key: + 'security:start_analysis:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }, + }); await expect( createHandlers().startAnalysis.handler({ @@ -662,6 +906,15 @@ describe('queue-backed handlers', () => { }); mockCanStartAnalysis.mockResolvedValue({ allowed: false, currentCount: 3, limit: 3 }); mockSubmitManualAnalysisStart.mockResolvedValue({ queued: true, commandId }); + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: { + id: 'ledger-row-id', + intent: 'start_analysis', + resource_key: + 'security:start_analysis:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }, + }); await expect( createHandlers().startAnalysis.handler({ @@ -1488,3 +1741,264 @@ describe('terminal command ledger settle', () => { error.mockRestore(); }); }); + +describe('getCommandStatuses', () => { + const batchCommandId = 'aaaabbbb-cccc-4ddd-8eee-ffff00002222'; + + function terminalCommand(overrides: Record = {}) { + return { + id: batchCommandId, + commandType: 'sync', + origin: 'dashboard_refresh', + findingId: null, + repoFullName: 'kilo/repo', + status: 'succeeded', + resultCode: 'SYNC_COMPLETED', + resultMetadata: null, + lastErrorRedacted: null, + acceptedAt: '2026-06-17T10:00:00.000Z', + startedAt: '2026-06-17T10:00:01.000Z', + completedAt: '2026-06-17T10:00:09.000Z', + updatedAt: '2026-06-17T10:00:09.000Z', + ...overrides, + }; + } + + async function insertLedgerRow(overrides: Partial = {}) { + const [row] = await db + .insert(operation_ledgers) + .values({ + operation_key: `settle-key-${randomUUID()}`, + domain: 'security', + intent: 'manual_sync', + kilo_user_id: 'user-123', + taxonomy: 'reconcile-first', + status: 'admitted', + provider_ref: batchCommandId, + admitted_at: '2026-06-17T10:00:00.000Z', + lease_expires_at: '2026-06-17T10:02:00.000Z', + expires_at: '2026-07-17T10:00:00.000Z', + ...overrides, + }) + .returning(); + return row!; + } + + beforeEach(async () => { + await db.delete(operation_ledgers).where(sql`true`); + }); + + afterAll(async () => { + await db.delete(operation_ledgers).where(sql`true`); + }); + + it('rejects an empty array, a non-uuid id, and more than 100 ids at the schema boundary', () => { + const schema = createHandlers().getCommandStatuses.inputSchema; + const ids = Array.from({ length: 101 }, () => '00000000-0000-4000-8000-000000000000'); + + expect(schema.safeParse({ commandIds: [] }).success).toBe(false); + expect(schema.safeParse({ commandIds: ['not-a-uuid'] }).success).toBe(false); + expect(schema.safeParse({ commandIds: ids }).success).toBe(false); + expect(schema.safeParse({ commandIds: ids.slice(0, 100) }).success).toBe(true); + }); + + it('returns only the commands the db layer resolved and never throws for unknown ids', async () => { + mockGetSecurityAgentCommandStatuses.mockResolvedValue([terminalCommand()]); + + await expect( + createHandlers().getCommandStatuses.handler({ + ctx: context, + input: { commandIds: [batchCommandId, '00000000-0000-4000-8000-000000000000'] }, + }) + ).resolves.toEqual([terminalCommand()]); + + expect(mockGetSecurityAgentCommandStatuses).toHaveBeenCalledWith( + { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + [batchCommandId, '00000000-0000-4000-8000-000000000000'] + ); + }); + + it('settles a terminal command exactly once across repeated batch calls', async () => { + const row = await insertLedgerRow(); + mockGetSecurityAgentCommandStatuses.mockResolvedValue([terminalCommand()]); + mockSettleOperation.mockImplementationOnce(async () => { + await db + .update(operation_ledgers) + .set({ status: 'completed' }) + .where(eq(operation_ledgers.id, row.id)); + return { settled: true }; + }); + + const handlers = createHandlers(); + await handlers.getCommandStatuses.handler({ + ctx: context, + input: { commandIds: [batchCommandId] }, + }); + await handlers.getCommandStatuses.handler({ + ctx: context, + input: { commandIds: [batchCommandId] }, + }); + + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + }); +}); + +describe('findings list DTO narrowing', () => { + function makeFullDecoratedFinding(): SecurityFindingWithRemediation { + return { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + platform_integration_id: null, + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + severity: 'high', + ghsa_id: 'GHSA-xxxx-yyyy-zzzz', + cve_id: 'CVE-2026-0001', + package_name: 'lodash', + package_ecosystem: 'npm', + vulnerable_version_range: '<4.17.21', + patched_version: '4.17.21', + manifest_path: 'package.json', + title: 'Prototype Pollution in lodash', + description: 'A prototype pollution vulnerability', + status: 'open', + ignored_reason: null, + ignored_by: null, + fixed_at: null, + sla_due_at: '2026-08-21T00:00:00.000Z', + dependabot_html_url: 'https://github.com/kilo/repo/security/dependabot/42', + cwe_ids: ['CWE-1321'], + cvss_score: '7.5', + dependency_scope: 'runtime', + session_id: null, + cli_session_id: null, + analysis_status: 'completed', + analysis_started_at: '2026-08-20T00:00:00.000Z', + analysis_completed_at: '2026-08-20T00:05:00.000Z', + analysis_error: null, + analysis: { + analyzedAt: '2026-08-20T00:05:00.000Z', + rawMarkdown: 'heavy analysis markdown', + modelUsed: 'analysis-model', + triage: { + needsSandboxAnalysis: true, + needsSandboxReasoning: 'needs sandbox', + suggestedAction: 'analyze_codebase', + confidence: 'high', + triageAt: '2026-08-20T00:04:00.000Z', + }, + sandboxAnalysis: { + isExploitable: 'unknown', + extractionStatus: 'failed', + exploitabilityReasoning: 'sandbox reasoning', + usageLocations: ['index.js'], + suggestedFix: 'upgrade lodash', + suggestedAction: 'monitor', + summary: 'sandbox summary', + rawMarkdown: 'sandbox markdown', + analysisAt: '2026-08-20T00:05:00.000Z', + modelUsed: 'sandbox-model', + }, + }, + raw_data: { number: 42, state: 'open' }, + first_detected_at: '2026-08-01T00:00:00.000Z', + last_synced_at: '2026-08-20T00:00:00.000Z', + created_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-20T00:05:00.000Z', + remediationSummary: { + id: 'remediation-id', + status: 'pr_opened', + latestAttemptId: 'attempt-id', + prUrl: 'https://github.com/kilo/repo/pull/99', + prNumber: 99, + prDraft: false, + prHeadBranch: 'fix/lodash', + prBaseBranch: 'main', + failureCode: null, + blockedReason: null, + outcomeSummary: 'PR opened', + completedAt: null, + updatedAt: '2026-08-20T00:10:00.000Z', + latestAttempt: { + id: 'attempt-id', + status: 'pr_opened', + origin: 'manual', + attemptNumber: 1, + requestedByUserId: 'user-123', + remediationModelSlug: 'remediation-model', + branchName: 'fix/lodash', + prUrl: 'https://github.com/kilo/repo/pull/99', + prNumber: 99, + prDraft: false, + prHeadBranch: 'fix/lodash', + prBaseBranch: 'main', + failureCode: null, + blockedReason: null, + lastErrorRedacted: null, + validationEvidence: null, + riskNotes: null, + draftReason: null, + cancellationRequestedAt: null, + queuedAt: '2026-08-20T00:06:00.000Z', + launchedAt: '2026-08-20T00:07:00.000Z', + completedAt: '2026-08-20T00:09:00.000Z', + createdAt: '2026-08-20T00:06:00.000Z', + updatedAt: '2026-08-20T00:09:00.000Z', + }, + }, + remediationCapability: { + canStart: false, + startReason: 'finding_not_open', + canRetry: false, + retryReason: 'finding_not_open', + canCancel: false, + cancelAttemptId: null, + }, + } as SecurityFindingWithRemediation; + } + + it('returns list rows with raw_data nulled', async () => { + const decoratedFinding = makeFullDecoratedFinding(); + mockListSecurityFindings.mockResolvedValue({ findings: [decoratedFinding], totalCount: 1 }); + mockDecorateFindingsWithRemediation.mockResolvedValue([decoratedFinding]); + mockCanStartAnalysis.mockResolvedValue({ allowed: true, currentCount: 0, limit: 3 }); + + const result = await createHandlers().listFindings.handler({ + ctx: context, + input: { sortBy: 'severity_desc', limit: 10, offset: 0 }, + }); + + const row = result.findings[0]!; + expect(row.raw_data).toBeNull(); + + // Exact key set: the full decorated row; nothing else added or dropped. + const expectedKeys = Object.keys(decoratedFinding).sort(); + expect(Object.keys(row).sort()).toEqual(expectedKeys); + + // analysis stays fully intact, including the heavy payloads the web + // detail dialog reads. + expect(row.analysis).toEqual(decoratedFinding.analysis); + + // remediationSummary stays fully intact, including latestAttempt. + expect(row.remediationSummary).toEqual(decoratedFinding.remediationSummary); + expect(row.remediationSummary?.latestAttempt).toBeDefined(); + }); + + it('keeps the heavy fields on the detail getFinding response', async () => { + const decoratedFinding = makeFullDecoratedFinding(); + mockGetSecurityFindingById.mockResolvedValue(decoratedFinding); + mockDecorateFindingWithRemediation.mockResolvedValue(decoratedFinding); + + const result = await createHandlers().getFinding.handler({ + ctx: context, + input: { id: decoratedFinding.id }, + }); + + expect(result).toEqual(decoratedFinding); + expect(result.raw_data).toBeDefined(); + expect(result.analysis).toEqual(decoratedFinding.analysis); + expect(result.remediationSummary?.latestAttempt).toBeDefined(); + }); +}); diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.ts b/apps/web/src/lib/security-agent/router/shared-handlers.ts index c4eef92811..d1ba213ecd 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -28,6 +28,7 @@ import { import { getDashboardStats } from '@/lib/security-agent/db/dashboard-stats'; import { getSecurityAgentCommandStatus, + getSecurityAgentCommandStatuses, listActiveSecurityAgentCommands, markApplyAutoRemediationCommandAdmissionFailed, type SecurityAgentCommandStatusResponse, @@ -37,6 +38,7 @@ import { decorateFindingWithRemediation, decorateFindingsWithRemediation, getRemediationAttemptHistory, + type SecurityFindingWithRemediation, } from '@/lib/security-agent/db/security-remediation'; import { SecurityAgentAuditReportInputSchema, @@ -66,12 +68,16 @@ import type { SecurityReviewOwner } from '@/lib/security-agent/core/types'; import { operation_ledgers, organizations, + security_audit_log, type OperationLedgerRow, type SecurityFinding, } from '@kilocode/db/schema'; -import { buildSecurityFindingAuditHumanActor } from '@kilocode/worker-utils/security-finding-audit'; +import { + buildSecurityFindingAuditHumanActor, + REPORTABLE_SECURITY_FINDING_AUDIT_ACTIONS, +} from '@kilocode/worker-utils/security-finding-audit'; import { db } from '@/lib/drizzle'; -import { and, eq } from 'drizzle-orm'; +import { and, asc, eq, inArray, sql } from 'drizzle-orm'; import { SaveSecurityConfigInputSchema, ListFindingsInputSchema, @@ -85,6 +91,7 @@ import { CancelRemediationInputSchema, GetAnalysisInputSchema, GetCommandStatusInputSchema, + GetCommandStatusesInputSchema, DeleteFindingsByRepoInputSchema, GetDashboardStatsInputSchema, TrackSecurityAgentUiInteractionInputSchema, @@ -100,6 +107,7 @@ import { type CancelRemediationInput, type GetAnalysisInput, type GetCommandStatusInput, + type GetCommandStatusesInput, type DeleteFindingsByRepoInput, type GetDashboardStatsInput, type TrackSecurityAgentUiInteractionInput, @@ -290,14 +298,30 @@ async function assembleAuditReportResponse(params: { // (network/5xx/lost correlation ids) marks the row `reconcile_pending` so a // same-key retry re-submits instead of re-executing blind. -type SecurityLedgerIntent = 'manual_sync' | 'dismiss_finding'; +type SecurityLedgerIntent = + | 'manual_sync' + | 'dismiss_finding' + | 'start_analysis' + | 'apply_auto_remediation'; + +const SECURITY_LEDGER_INTENTS: readonly SecurityLedgerIntent[] = [ + 'manual_sync', + 'dismiss_finding', + 'start_analysis', + 'apply_auto_remediation', +]; + +function isSecurityLedgerIntent(value: string): value is SecurityLedgerIntent { + return (SECURITY_LEDGER_INTENTS as readonly string[]).includes(value); +} /** The Worker's acceptance receipt and correlation ids. */ type AcceptedCommandIds = { accepted: true; commandId: string; - runId: string; - messageId: string; + /** Correlation ids are absent for intents without a queue run (analysis). */ + runId?: string; + messageId?: string; }; type SecurityCommandResult = @@ -351,6 +375,91 @@ function securityDismissLedgerResourceKey( return `security:dismiss_finding:${securityOwnerScopeKey(owner)}:${findingId}:${reason}:${normalizeDismissComment(comment)}`; } +/** Security ledger resource identity for a manual analysis start. */ +function securityAnalysisLedgerResourceKey(owner: SecurityReviewOwner, findingId: string): string { + return `security:start_analysis:${securityOwnerScopeKey(owner)}:${findingId}`; +} + +/** Security ledger resource identity for a remediation attempt. */ +function securityRemediationLedgerResourceKey( + owner: SecurityReviewOwner, + findingId: string +): string { + return `security:apply_auto_remediation:${securityOwnerScopeKey(owner)}:${findingId}`; +} + +/** + * The ledger user id for a security owner. Personal scope uses the owner user. + * Org scope has no single acting user, so it approximates with the + * organization's `created_by_kilo_user_id` (the `kilo_user_id` column is NOT + * NULL). Returns null when the org has no creator, in which case the admit is + * skipped and the terminal settle later skips on the missing row. + */ +async function resolveSecurityLedgerUserId(owner: SecurityReviewOwner): Promise { + if (!('organizationId' in owner) || !owner.organizationId) { + return owner.userId ?? null; + } + const [org] = await db + .select({ createdBy: organizations.created_by_kilo_user_id }) + .from(organizations) + .where(eq(organizations.id, owner.organizationId)) + .limit(1); + return org?.createdBy ?? null; +} + +/** + * Admits a `security`-domain ledger row for an already-created remediation + * attempt and records the attempt id as the provider reference. The Worker + * settles the row from the terminal callback. Best-effort: the remediation + * effect already committed, so a ledger write failure must not fail the + * request; the terminal settle then skips on the missing row. + */ +async function admitSecurityRemediationLedgerRow(params: { + owner: SecurityReviewOwner; + findingId: string; + attemptId: string; + remediationId: string; + attemptNumber: number; +}): Promise { + try { + const userId = await resolveSecurityLedgerUserId(params.owner); + if (!userId) { + console.error('Skipping security remediation ledger admission: no ledger user id', { + attemptId: params.attemptId, + }); + return; + } + const admission = await admitOperation(db, { + userId, + orgId: + 'organizationId' in params.owner && params.owner.organizationId + ? params.owner.organizationId + : null, + domain: 'security', + intent: 'apply_auto_remediation', + operationKey: `remediation:${params.attemptId}`, + resourceKey: securityRemediationLedgerResourceKey(params.owner, params.findingId), + taxonomy: 'reconcile-first', + leaseSeconds: 120, + }); + if (admission.admission !== 'admitted') return; + await recordOperationAcceptance(db, { + rowId: admission.row.id, + providerRef: params.attemptId, + canonicalResult: { + attemptId: params.attemptId, + remediationId: params.remediationId, + attemptNumber: params.attemptNumber, + }, + }); + } catch (error) { + console.error('Failed to admit the security remediation ledger row', { + attemptId: params.attemptId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * Runs a ledger write whose failure must never produce a success receipt: * `work` resolves false when nothing durable was written. Both cases surface @@ -465,8 +574,8 @@ async function executeSecurityCommandSubmit(args: { providerRef: accepted.commandId, canonicalResult: { commandId: accepted.commandId, - runId: accepted.runId, - messageId: accepted.messageId, + ...(accepted.runId !== undefined ? { runId: accepted.runId } : {}), + ...(accepted.messageId !== undefined ? { messageId: accepted.messageId } : {}), }, })) !== null ); @@ -575,7 +684,7 @@ async function settleSecurityLedgerForTerminalCommand(params: { ) .limit(1); if (!row || isTerminalOperationStatus(row.status)) return; - if (row.intent !== 'manual_sync' && row.intent !== 'dismiss_finding') return; + if (!isSecurityLedgerIntent(row.intent)) return; await settleOperation(db, { rowId: row.id, @@ -596,6 +705,68 @@ async function settleSecurityLedgerForTerminalCommand(params: { } } +// --------------------------------------------------------------------------- +// Findings list DTO +// --------------------------------------------------------------------------- +// +// The list response nulls `raw_data`, the raw Dependabot alert JSON. It is the +// only heavy field no list UI reads. The detail procedure `getFinding` still +// returns it. The web detail dialog is fed from the list +// and reads `analysis` and `remediationSummary` (including `latestAttempt`), +// so both stay fully intact. The decorator needs the full row, so the SQL +// select stays untouched and the response nulls `raw_data` after decoration. + +function toFindingListItem( + finding: SecurityFindingWithRemediation +): SecurityFindingWithRemediation { + return { ...finding, raw_data: null }; +} + +// --------------------------------------------------------------------------- +// Remediation progress timeline (detail-only) +// --------------------------------------------------------------------------- +// +// The remediation panel renders the ordered remediation audit events for one +// finding. The list decorator stays lean (P2-GH-45a); this detail-only query +// reads the audit log directly. Only the remediation members of +// REPORTABLE_SECURITY_FINDING_AUDIT_ACTIONS are timeline events — finding +// lifecycle events are not. + +// The remediation members of REPORTABLE_SECURITY_FINDING_AUDIT_ACTIONS are the +// timeline events. Filtered from the worker-utils constant (not the +// audit-log-service enum, which tests mock with a partial object) so the six +// remediation action strings stay real. +const REMEDIATION_TIMELINE_ACTIONS = REPORTABLE_SECURITY_FINDING_AUDIT_ACTIONS.filter(action => + action.startsWith('security.remediation.') +); + +type RemediationTimelineEvent = { + action: string; + occurredAt: string; +}; + +async function getRemediationTimeline(findingId: string): Promise { + const effectiveAt = sql`COALESCE(${security_audit_log.occurred_at}, ${security_audit_log.created_at})`; + const rows = await db + .select({ + action: security_audit_log.action, + occurredAt: effectiveAt, + }) + .from(security_audit_log) + .where( + and( + eq(security_audit_log.finding_id, findingId), + inArray(security_audit_log.action, [...REMEDIATION_TIMELINE_ACTIONS]) + ) + ) + .orderBy(asc(effectiveAt)); + + return rows.map(row => ({ + action: row.action, + occurredAt: new Date(row.occurredAt).toISOString(), + })); +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -698,6 +869,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps autoRemediationEnabled: false, autoRemediationMinSeverity: 'high' as const, autoRemediationIncludeExisting: false, + autoRemediationRequireApproval: true, autoRemediationEnabledAt: null, remediationModelSlug: DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, slaNotificationsEnabled: DEFAULT_SECURITY_AGENT_CONFIG.sla_notifications_enabled, @@ -750,6 +922,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps autoRemediationEnabled: result.config.auto_remediation_enabled ?? false, autoRemediationMinSeverity: result.config.auto_remediation_min_severity ?? 'high', autoRemediationIncludeExisting: result.config.auto_remediation_include_existing ?? false, + autoRemediationRequireApproval: result.config.auto_remediation_require_approval ?? true, autoRemediationEnabledAt: result.config.auto_remediation_enabled_at ?? null, remediationModelSlug, slaNotificationsEnabled: result.config.sla_notifications_enabled, @@ -876,12 +1049,17 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps isNowRemediationIncludeExisting && !!input.autoRemediationMinSeverity && input.autoRemediationMinSeverity !== existingConfig?.config.auto_remediation_min_severity; + const wasApprovalRequired = + existingConfig?.config.auto_remediation_require_approval ?? false; + const isNowApprovalRequired = input.autoRemediationRequireApproval ?? wasApprovalRequired; + const approvalJustTurnedOff = !isNowApprovalRequired && wasApprovalRequired; const shouldEnqueueRemediation = isAutoRemediationOn && (remediationIncludeExistingJustTurnedOn || autoRemediationReEnabled || - remediationThresholdChanged); + remediationThresholdChanged || + approvalJustTurnedOff); // Compare-and-set save: the config write, the include-existing analysis // enqueue, and the include-existing remediation command all commit in one @@ -909,6 +1087,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps auto_remediation_enabled: input.autoRemediationEnabled, auto_remediation_min_severity: input.autoRemediationMinSeverity, auto_remediation_include_existing: input.autoRemediationIncludeExisting, + auto_remediation_require_approval: input.autoRemediationRequireApproval, remediation_model_slug: remediationModelSlug, sla_notifications_enabled: input.slaNotificationsEnabled, sla_notification_min_severity: input.slaNotificationMinSeverity, @@ -1293,7 +1472,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); return { - findings: decoratedFindings, + findings: decoratedFindings.map(toFindingListItem), totalCount, runningCount: concurrencyCheck.currentCount, concurrencyLimit: concurrencyCheck.limit, @@ -1670,21 +1849,41 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps } } - const queued = await submitManualAnalysisStart({ - findingId: input.findingId, + // Analysis has no client `operationKey`, so each start attempt gets a + // fresh UUID key. The ledger row is admitted before submission and the + // Worker `commandId` is recorded as the provider reference, which the + // web observation settle later joins on to emit + // `security_command_settled` with the `start_analysis` intent. + const result: SecurityCommandResult = await runSecurityLedgerSubmit({ + ctx, owner: securityOwner, - actorUserId: ctx.user.id, - requestedModels: { - model: input.model, - triageModel: input.triageModel, - analysisModel: input.analysisModel, + intent: 'start_analysis', + operationKey: crypto.randomUUID(), + resourceKey: securityAnalysisLedgerResourceKey(securityOwner, input.findingId), + submit: async () => { + const { commandId } = await submitManualAnalysisStart({ + findingId: input.findingId, + owner: securityOwner, + actorUserId: ctx.user.id, + requestedModels: { + model: input.model, + triageModel: input.triageModel, + analysisModel: input.analysisModel, + }, + forceSandbox: input.forceSandbox, + retrySandboxOnly: input.retrySandboxOnly, + restartActive: input.restartActive, + }); + return { accepted: true, commandId }; }, - forceSandbox: input.forceSandbox, - retrySandboxOnly: input.retrySandboxOnly, - restartActive: input.restartActive, }); - return { success: true, ...queued }; + if (result.kind === 'replayed') { + const commandId = + typeof result.canonical.commandId === 'string' ? result.canonical.commandId : undefined; + return { success: true, queued: true, commandId }; + } + return { success: true, queued: true, commandId: result.accepted.commandId }; }, }, @@ -1725,6 +1924,17 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); if (!queued.queued) return { success: false, ...queued }; + // Admit the remediation ledger row after the attempt committed so the + // Worker's terminal callback can settle it. Best-effort: the attempt + // already exists, so a ledger write failure must not fail the request. + await admitSecurityRemediationLedgerRow({ + owner: securityOwner, + findingId: input.findingId, + attemptId: queued.attemptId, + remediationId: queued.remediationId, + attemptNumber: queued.attemptNumber, + }); + trackSecurityAgentRemediationAction({ distinctId: ctx.user.id, userId: ctx.user.id, @@ -1774,6 +1984,14 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); if (!queued.queued) return { success: false, ...queued }; + await admitSecurityRemediationLedgerRow({ + owner: securityOwner, + findingId: input.findingId, + attemptId: queued.attemptId, + remediationId: queued.remediationId, + attemptNumber: queued.attemptNumber, + }); + trackSecurityAgentRemediationAction({ distinctId: ctx.user.id, userId: ctx.user.id, @@ -1847,11 +2065,13 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps } const owner = deps.resolveOwner(ctx, input); - const [configWithStatus, integration, remediationAttempts] = await Promise.all([ - getSecurityAgentConfigWithStatus(owner), - deps.getIntegration(ctx, input), - getRemediationAttemptHistory(input.findingId), - ]); + const [configWithStatus, integration, remediationAttempts, remediationTimeline] = + await Promise.all([ + getSecurityAgentConfigWithStatus(owner), + deps.getIntegration(ctx, input), + getRemediationAttemptHistory(input.findingId), + getRemediationTimeline(input.findingId), + ]); const config = configWithStatus?.config ?? DEFAULT_SECURITY_AGENT_CONFIG; const decoratedFinding = await decorateFindingWithRemediation({ finding, @@ -1878,6 +2098,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps remediationSummary: decoratedFinding.remediationSummary ?? null, remediationCapability: decoratedFinding.remediationCapability, remediationAttempts, + remediationTimeline, }; }, }, @@ -1905,6 +2126,29 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }, }, + // ----------------------------------------------------------------------- + // getCommandStatuses (batch) + // ----------------------------------------------------------------------- + // Compatibility: getCommandStatus (single) kept for older mobile clients; remove when all shipped clients call getCommandStatuses. + getCommandStatuses: { + inputSchema: GetCommandStatusesInputSchema, + handler: async ({ + ctx, + input: rawInput, + }: { + ctx: TRPCContext; + input: GetCommandStatusesInput & TExtra; + }) => { + const input = rawInput; + const securityOwner = deps.resolveSecurityOwner(ctx, input); + const commands = await getSecurityAgentCommandStatuses(securityOwner, input.commandIds); + for (const command of commands) { + await settleSecurityLedgerForTerminalCommand({ ctx, command }); + } + return commands; + }, + }, + // ----------------------------------------------------------------------- // 18. listActiveCommands // ----------------------------------------------------------------------- diff --git a/apps/web/src/lib/security-agent/security-ledger-settled-outcomes.test.ts b/apps/web/src/lib/security-agent/security-ledger-settled-outcomes.test.ts new file mode 100644 index 0000000000..3e69d6e39e --- /dev/null +++ b/apps/web/src/lib/security-agent/security-ledger-settled-outcomes.test.ts @@ -0,0 +1,402 @@ +/** + * @jest-environment node + * + * Security settled-outcome coverage for the two intents added in P1-A-07c: + * `start_analysis` (admitted through `runSecurityLedgerSubmit`, settled by the + * web observation settle) and `apply_auto_remediation` (admitted after the + * manual remediation attempt commits, keyed `remediation:` with + * `provider_ref = attemptId`). The ledger helpers are mocked; the observation + * settle reads the real `operation_ledgers` table, so rows are inserted + * directly. + */ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { createSecurityAgentHandlers as createSecurityAgentHandlersType } from './router/shared-handlers'; +import type * as manualAnalysisClientModule from './services/manual-analysis-client'; +import type * as manualRemediationClientModule from './services/manual-remediation-client'; +import { randomUUID } from 'crypto'; +import { sql } from 'drizzle-orm'; +import { db } from '@/lib/drizzle'; +import { operation_ledgers, type OperationLedgerRow } from '@kilocode/db/schema'; + +const mockSubmitManualAnalysisStart = jest.fn() as jest.MockedFunction< + typeof manualAnalysisClientModule.submitManualAnalysisStart +>; +const mockSubmitManualRemediationStart = jest.fn() as jest.MockedFunction< + typeof manualRemediationClientModule.submitManualRemediationStart +>; +const mockGetSecurityFindingById = jest.fn<() => Promise>(); +const mockCanStartAnalysis = jest.fn<(owner: unknown) => Promise>(); +const mockGetSecurityAgentCommandStatus = jest.fn<(...args: unknown[]) => Promise>(); +const mockTrackSecurityAgentRemediationAction = jest.fn(); +const mockAdmitOperation = jest.fn<(...args: unknown[]) => Promise>(); +const mockMarkReconcilePending = jest.fn<(...args: unknown[]) => Promise>(); +const mockRecordOperationAcceptance = jest.fn<(...args: unknown[]) => Promise>(); +const mockSettleOperation = jest.fn<(...args: unknown[]) => Promise>(); + +jest.mock('./services/manual-analysis-client', () => ({ + submitManualAnalysisStart: mockSubmitManualAnalysisStart, +})); +jest.mock('./services/manual-remediation-client', () => ({ + submitApplyAutoRemediation: jest.fn(), + submitManualRemediationStart: mockSubmitManualRemediationStart, + submitRemediationCancellation: jest.fn(), +})); +jest.mock('./services/manual-sync-client', () => ({ submitManualSecuritySync: jest.fn() })); +jest.mock('./services/manual-dismiss-client', () => ({ submitManualFindingDismissal: jest.fn() })); +jest.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: mockAdmitOperation, + isTerminalOperationStatus: (status: string) => + ['completed', 'failed', 'no_op', 'interrupted', 'superseded'].includes(status), + markReconcilePending: mockMarkReconcilePending, + recordOperationAcceptance: mockRecordOperationAcceptance, + settleOperation: mockSettleOperation, +})); +jest.mock('./github/permissions', () => ({ + hasSecurityReviewPermissions: () => true, + getReauthorizeUrl: jest.fn(), +})); +jest.mock('./github/dependabot-api', () => ({ + checkDependabotAlertsAvailability: jest.fn(), +})); +jest.mock('./posthog-tracking', () => ({ + trackSecurityAgentEnabled: jest.fn(), + trackSecurityAgentConfigSaved: jest.fn(), + trackSecurityAgentSync: jest.fn(), + trackSecurityAgentFindingDismissed: jest.fn(), + trackSecurityAgentUiInteraction: jest.fn(), + trackSecurityAgentRemediationAction: mockTrackSecurityAgentRemediationAction, +})); +jest.mock('./services/audit-log-service', () => ({ + createSecurityAuditLog: jest.fn(), + logSecurityAudit: jest.fn(), + SecurityAuditLogAction: {}, +})); +jest.mock('./db/security-config', () => ({ + getSecurityAgentConfigWithStatus: jest.fn(), + upsertSecurityAgentConfig: jest.fn(), + saveSecurityAgentConfigWithRevision: jest.fn(), + setSecurityAgentEnabled: jest.fn(), +})); +jest.mock('./db/security-findings', () => ({ + listSecurityFindings: jest.fn(), + getSecurityFindingById: mockGetSecurityFindingById, + getSecurityFindingsSummary: jest.fn(), + getLastSyncTime: jest.fn(), + getOrphanedRepositoriesWithFindingCounts: jest.fn(), + deleteFindingsByRepository: jest.fn(), +})); +jest.mock('./db/security-remediation', () => ({ + decorateFindingWithRemediation: jest.fn(), + decorateFindingsWithRemediation: jest.fn(), + getRemediationAttemptHistory: jest.fn(), +})); +jest.mock('./db/security-commands', () => ({ + getSecurityAgentCommandStatus: mockGetSecurityAgentCommandStatus, + getSecurityAgentCommandStatuses: jest.fn(), + listActiveSecurityAgentCommands: jest.fn(), +})); +jest.mock('./db/dashboard-stats', () => ({ getDashboardStats: jest.fn() })); +jest.mock('./db/security-analysis', () => ({ + canStartAnalysis: mockCanStartAnalysis, + enqueueBacklogFindings: jest.fn(), +})); +jest.mock('./services/auto-dismiss-service', () => ({ + autoDismissEligibleFindings: jest.fn(), + countEligibleForAutoDismiss: jest.fn(), +})); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + updateRepositoriesForIntegration: jest.fn(), +})); +jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + fetchGitHubRepositories: jest.fn(), +})); + +let createSecurityAgentHandlers: typeof createSecurityAgentHandlersType; + +beforeAll(async () => { + ({ createSecurityAgentHandlers } = await import('./router/shared-handlers')); +}); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const COMMAND_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + +const context = { + user: { + id: 'user-123', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + is_admin: false, + }, +} as never; + +function createPersonalHandlers() { + return createSecurityAgentHandlers({ + resolveOwner: () => ({ type: 'user', id: 'user-123', userId: 'user-123' }), + resolveSecurityOwner: () => ({ userId: 'user-123' }), + resolveResourceId: () => 'user-123', + verifyFindingOwnership: () => true, + getIntegration: async () => ({ integration_status: 'active' }) as never, + trackingExtras: () => ({}), + }); +} + +function createOrgHandlers() { + return createSecurityAgentHandlers({ + resolveOwner: () => ({ type: 'org', id: ORG_ID, userId: 'user-123' }), + resolveSecurityOwner: () => ({ organizationId: ORG_ID }), + resolveResourceId: () => ORG_ID, + verifyFindingOwnership: () => true, + getIntegration: async () => ({ integration_status: 'active' }) as never, + trackingExtras: () => ({}), + }); +} + +function ledgerRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: 'ledger-row-id', + operation_key: 'analysis-key', + domain: 'security', + intent: 'start_analysis', + kilo_user_id: 'user-123', + organization_id: ORG_ID, + resource_key: `security:start_analysis:org:${ORG_ID}:${FINDING_ID}`, + provider_ref: null, + taxonomy: 'reconcile-first', + status: 'admitted', + outcome_code: null, + canonical_result: null, + admitted_at: '2026-06-17T10:00:00.000Z', + settled_at: null, + lease_expires_at: '2026-06-17T10:02:00.000Z', + expires_at: '2026-07-17T10:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockRecordOperationAcceptance.mockResolvedValue({ status: 'admitted' }); + mockMarkReconcilePending.mockResolvedValue({ status: 'reconcile_pending' }); + mockSettleOperation.mockResolvedValue({ settled: true }); +}); + +describe('analysis start ledger admission', () => { + beforeEach(() => { + mockGetSecurityFindingById.mockResolvedValue({ id: FINDING_ID }); + mockCanStartAnalysis.mockResolvedValue({ allowed: true, currentCount: 0, limit: 3 }); + mockSubmitManualAnalysisStart.mockResolvedValue({ queued: true, commandId: COMMAND_ID }); + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ resource_key: `security:start_analysis:org:${ORG_ID}:${FINDING_ID}` }), + }); + }); + + it('admits with intent start_analysis and a fresh UUID operation key', async () => { + await expect( + createOrgHandlers().startAnalysis.handler({ + ctx: context, + input: { findingId: FINDING_ID }, + }) + ).resolves.toEqual({ success: true, queued: true, commandId: COMMAND_ID }); + + const admitInput = mockAdmitOperation.mock.calls[0]?.[1] as { + intent: string; + operationKey: string; + resourceKey: string; + }; + expect(admitInput).toMatchObject({ + intent: 'start_analysis', + resourceKey: `security:start_analysis:org:${ORG_ID}:${FINDING_ID}`, + }); + expect(admitInput.operationKey).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(admitInput.operationKey).not.toBe(FINDING_ID); + + // Analysis has no queue run, so the acceptance records only the commandId. + expect(mockRecordOperationAcceptance.mock.calls[0]?.[1]).toEqual({ + rowId: 'ledger-row-id', + providerRef: COMMAND_ID, + canonicalResult: { commandId: COMMAND_ID }, + }); + }); +}); + +describe('analysis web observation settle', () => { + async function insertAnalysisLedgerRow() { + const [row] = await db + .insert(operation_ledgers) + .values({ + operation_key: `analysis-${randomUUID()}`, + domain: 'security', + intent: 'start_analysis', + kilo_user_id: 'user-123', + taxonomy: 'reconcile-first', + status: 'admitted', + provider_ref: COMMAND_ID, + admitted_at: '2026-06-17T10:00:00.000Z', + lease_expires_at: '2026-06-17T10:02:00.000Z', + expires_at: '2026-07-17T10:00:00.000Z', + }) + .returning(); + return row!; + } + + beforeEach(async () => { + await db.delete(operation_ledgers).where(sql`true`); + mockGetSecurityAgentCommandStatus.mockResolvedValue({ + id: COMMAND_ID, + commandType: 'start_analysis', + origin: 'dashboard_refresh', + findingId: FINDING_ID, + repoFullName: null, + status: 'succeeded', + resultCode: 'ANALYSIS_COMPLETED', + resultMetadata: null, + lastErrorRedacted: null, + acceptedAt: '2026-06-17T10:00:00.000Z', + startedAt: '2026-06-17T10:00:01.000Z', + completedAt: '2026-06-17T10:00:09.000Z', + updatedAt: '2026-06-17T10:00:09.000Z', + }); + }); + + afterAll(async () => { + await db.delete(operation_ledgers).where(sql`true`); + }); + + it('settles an admitted start_analysis row with the start_analysis intent', async () => { + await insertAnalysisLedgerRow(); + + await expect( + createOrgHandlers().getCommandStatus.handler({ + ctx: context, + input: { commandId: COMMAND_ID }, + }) + ).resolves.toMatchObject({ id: COMMAND_ID, status: 'succeeded' }); + + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + const settleInput = mockSettleOperation.mock.calls[0]?.[1] as { + status: string; + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleInput.status).toBe('completed'); + expect(settleInput.outboxEvent.eventName).toBe('security_command_settled'); + expect(settleInput.outboxEvent.properties).toMatchObject({ + intent: 'start_analysis', + outcome: 'completed', + }); + }); + + it('emits only the contract keys for the security_command_settled payload', async () => { + await insertAnalysisLedgerRow(); + await createOrgHandlers().getCommandStatus.handler({ + ctx: context, + input: { commandId: COMMAND_ID }, + }); + + const settleInput = mockSettleOperation.mock.calls[0]?.[1] as { + outboxEvent: { properties: Record }; + }; + expect(Object.keys(settleInput.outboxEvent.properties).sort()).toEqual([ + 'duration_ms', + 'intent', + 'outcome', + 'phase', + 'source', + 'surface', + ]); + }); + + it('skips without throwing when no ledger row exists for the command', async () => { + await expect( + createOrgHandlers().getCommandStatus.handler({ + ctx: context, + input: { commandId: COMMAND_ID }, + }) + ).resolves.toMatchObject({ id: COMMAND_ID }); + + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); +}); + +describe('remediation manual submit ledger admission', () => { + const ATTEMPT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const REMEDIATION_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + + beforeEach(() => { + mockGetSecurityFindingById.mockResolvedValue({ id: FINDING_ID }); + mockSubmitManualRemediationStart.mockResolvedValue({ + queued: true, + remediationId: REMEDIATION_ID, + attemptId: ATTEMPT_ID, + attemptNumber: 1, + }); + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'apply_auto_remediation', + operation_key: `remediation:${ATTEMPT_ID}`, + resource_key: `security:apply_auto_remediation:user:user-123:${FINDING_ID}`, + }), + }); + }); + + it('admits with operation key remediation: and records provider_ref = attemptId', async () => { + await expect( + createPersonalHandlers().startRemediation.handler({ + ctx: context, + input: { findingId: FINDING_ID }, + }) + ).resolves.toEqual({ + success: true, + queued: true, + remediationId: REMEDIATION_ID, + attemptId: ATTEMPT_ID, + attemptNumber: 1, + }); + + const admitInput = mockAdmitOperation.mock.calls[0]?.[1] as { + intent: string; + operationKey: string; + resourceKey: string; + }; + expect(admitInput).toMatchObject({ + intent: 'apply_auto_remediation', + operationKey: `remediation:${ATTEMPT_ID}`, + resourceKey: `security:apply_auto_remediation:user:user-123:${FINDING_ID}`, + }); + + expect(mockRecordOperationAcceptance.mock.calls[0]?.[1]).toEqual({ + rowId: 'ledger-row-id', + providerRef: ATTEMPT_ID, + canonicalResult: { + attemptId: ATTEMPT_ID, + remediationId: REMEDIATION_ID, + attemptNumber: 1, + }, + }); + }); + + it('does not fail the request when the remediation ledger admit fails', async () => { + mockAdmitOperation.mockRejectedValue(new Error('database unavailable')); + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect( + createPersonalHandlers().startRemediation.handler({ + ctx: context, + input: { findingId: FINDING_ID }, + }) + ).resolves.toEqual({ + success: true, + queued: true, + remediationId: REMEDIATION_ID, + attemptId: ATTEMPT_ID, + attemptNumber: 1, + }); + + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts index a800288e31..887227965d 100644 --- a/apps/web/src/routers/code-reviews-router.test.ts +++ b/apps/web/src/routers/code-reviews-router.test.ts @@ -84,6 +84,7 @@ import { updateCheckRun } from '@/lib/integrations/platforms/github/adapter'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createTestOrganization } from '@/tests/helpers/organization.helper'; +import { addUserToOrganization } from '@/lib/organizations/organizations'; import { agent_configs, cloud_agent_code_review_attempts, @@ -385,6 +386,197 @@ describe('codeReviewRouter.cancel', () => { }); }); +describe('codeReviewRouter.listForOrganization role-gated ids', () => { + let ownerUser: User; + let memberUser: User; + let organization: Organization; + + beforeAll(async () => { + ownerUser = await insertTestUser({ + google_user_email: 'list-ids-owner@example.com', + google_user_name: 'List Ids Owner', + is_admin: false, + }); + memberUser = await insertTestUser({ + google_user_email: 'list-ids-member@example.com', + google_user_name: 'List Ids Member', + is_admin: false, + }); + organization = await createTestOrganization('List Ids Org', ownerUser.id, 0, {}, false); + await addUserToOrganization(organization.id, memberUser.id, 'member'); + }); + + afterAll(async () => { + await db + .delete(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.owned_by_organization_id, organization.id)); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, organization.id)); + await db.delete(organizations).where(eq(organizations.id, organization.id)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, memberUser.id)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, ownerUser.id)); + }); + + it('nulls raw ledger/transaction ids for members and keeps them for owners', async () => { + const [review] = await db + .insert(cloud_agent_code_reviews) + .values({ + owned_by_organization_id: organization.id, + owned_by_user_id: null, + platform_integration_id: null, + repo_full_name: 'test-org/list-ids-repo', + pr_number: 1, + pr_url: 'https://github.com/test-org/list-ids-repo/pull/1', + pr_title: 'List ids PR', + pr_author: 'octocat', + base_ref: 'main', + head_ref: 'feature/list-ids', + head_sha: 'sha-list-ids', + status: 'completed', + session_id: 'agent-session-list-ids', + cli_session_id: 'ses-list-ids', + dispatch_reservation_id: 'reservation-list-ids', + check_run_id: 12345, + total_cost_musd: 500, + }) + .returning({ id: cloud_agent_code_reviews.id }); + + try { + const memberCaller = await createCallerForUser(memberUser.id); + const memberResult = await memberCaller.codeReviews.listForOrganization({ + organizationId: organization.id, + }); + expect(memberResult.success).toBe(true); + if (!memberResult.success) throw new Error('expected member list success'); + const memberReview = memberResult.reviews.find(r => r.id === review.id); + expect(memberReview).toBeDefined(); + expect(memberReview?.session_id).toBeNull(); + expect(memberReview?.cli_session_id).toBeNull(); + expect(memberReview?.dispatch_reservation_id).toBeNull(); + expect(memberReview?.check_run_id).toBeNull(); + expect(memberReview?.total_cost_musd).toBe(500); + + const ownerCaller = await createCallerForUser(ownerUser.id); + const ownerResult = await ownerCaller.codeReviews.listForOrganization({ + organizationId: organization.id, + }); + expect(ownerResult.success).toBe(true); + if (!ownerResult.success) throw new Error('expected owner list success'); + const ownerReview = ownerResult.reviews.find(r => r.id === review.id); + expect(ownerReview).toBeDefined(); + expect(ownerReview?.session_id).toBe('agent-session-list-ids'); + expect(ownerReview?.cli_session_id).toBe('ses-list-ids'); + expect(ownerReview?.dispatch_reservation_id).toBe('reservation-list-ids'); + expect(ownerReview?.check_run_id).toBe(12345); + expect(ownerReview?.total_cost_musd).toBe(500); + } finally { + await db.delete(cloud_agent_code_reviews).where(eq(cloud_agent_code_reviews.id, review.id)); + } + }); +}); + +describe('codeReviewRouter.get role-gated ids', () => { + let ownerUser: User; + let memberUser: User; + let organization: Organization; + + beforeAll(async () => { + ownerUser = await insertTestUser({ + google_user_email: 'get-ids-owner@example.com', + google_user_name: 'Get Ids Owner', + is_admin: false, + }); + memberUser = await insertTestUser({ + google_user_email: 'get-ids-member@example.com', + google_user_name: 'Get Ids Member', + is_admin: false, + }); + organization = await createTestOrganization('Get Ids Org', ownerUser.id, 0, {}, false); + await addUserToOrganization(organization.id, memberUser.id, 'member'); + }); + + afterAll(async () => { + await db + .delete(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.owned_by_organization_id, organization.id)); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, organization.id)); + await db.delete(organizations).where(eq(organizations.id, organization.id)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, memberUser.id)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, ownerUser.id)); + }); + + it('nulls raw identifiers for members and keeps them for owners', async () => { + const [review] = await db + .insert(cloud_agent_code_reviews) + .values({ + owned_by_organization_id: organization.id, + owned_by_user_id: null, + platform_integration_id: null, + repo_full_name: 'test-org/get-ids-repo', + pr_number: 1, + pr_url: 'https://github.com/test-org/get-ids-repo/pull/1', + pr_title: 'Get ids PR', + pr_author: 'octocat', + base_ref: 'main', + head_ref: 'feature/get-ids', + head_sha: 'sha-get-ids', + status: 'completed', + session_id: 'agent-session-get-ids', + cli_session_id: 'ses-get-ids', + dispatch_reservation_id: 'reservation-get-ids', + check_run_id: 12345, + total_cost_musd: 500, + }) + .returning({ id: cloud_agent_code_reviews.id }); + + await db.insert(cloud_agent_code_review_attempts).values({ + code_review_id: review.id, + attempt_number: 1, + status: 'completed', + session_id: 'agent-attempt-get-ids', + cli_session_id: 'ses-attempt-get-ids', + execution_id: 'exec-attempt-get-ids', + }); + + try { + const memberCaller = await createCallerForUser(memberUser.id); + const memberResult = await memberCaller.codeReviews.get({ reviewId: review.id }); + expect(memberResult.success).toBe(true); + if (!memberResult.success) throw new Error('expected member get success'); + expect(memberResult.review.session_id).toBeNull(); + expect(memberResult.review.cli_session_id).toBeNull(); + expect(memberResult.review.dispatch_reservation_id).toBeNull(); + expect(memberResult.review.check_run_id).toBeNull(); + expect(memberResult.review.rawIdsRedacted).toBe(true); + expect(memberResult.review.total_cost_musd).toBe(500); + expect(memberResult.attempts).toHaveLength(1); + expect(memberResult.attempts[0]?.session_id).toBeNull(); + expect(memberResult.attempts[0]?.cli_session_id).toBeNull(); + expect(memberResult.attempts[0]?.execution_id).toBeNull(); + + const ownerCaller = await createCallerForUser(ownerUser.id); + const ownerResult = await ownerCaller.codeReviews.get({ reviewId: review.id }); + expect(ownerResult.success).toBe(true); + if (!ownerResult.success) throw new Error('expected owner get success'); + expect(ownerResult.review.session_id).toBe('agent-session-get-ids'); + expect(ownerResult.review.cli_session_id).toBe('ses-get-ids'); + expect(ownerResult.review.dispatch_reservation_id).toBe('reservation-get-ids'); + expect(ownerResult.review.check_run_id).toBe(12345); + expect(ownerResult.review.rawIdsRedacted).toBe(false); + expect(ownerResult.review.total_cost_musd).toBe(500); + expect(ownerResult.attempts).toHaveLength(1); + expect(ownerResult.attempts[0]?.session_id).toBe('agent-attempt-get-ids'); + expect(ownerResult.attempts[0]?.cli_session_id).toBe('ses-attempt-get-ids'); + expect(ownerResult.attempts[0]?.execution_id).toBe('exec-attempt-get-ids'); + } finally { + await db.delete(cloud_agent_code_reviews).where(eq(cloud_agent_code_reviews.id, review.id)); + } + }); +}); + describe('personalReviewAgent.createManualReviewJob', () => { let testUser: User; let fetchSpy: jest.SpiedFunction | null = null; @@ -2478,6 +2670,25 @@ describe('personalReviewAgent.patchReviewConfig', () => { }); } + // Seeds a minimal personal config with an explicit selected_repository_ids + // array, used by the delta repository save tests (P2-GH-45c). + async function seedPersonalSelection(selectedRepositoryIds: number[]) { + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: selectedRepositoryIds, + }, + is_enabled: false, + created_by: testUser.id, + }); + } + it('returns NOT_FOUND when no stored personal config exists', async () => { const caller = await createCallerForUser(testUser.id); @@ -2720,6 +2931,157 @@ describe('personalReviewAgent.patchReviewConfig', () => { }) ); }); + + // P2-GH-45c: delta repository save. The mobile delta sender lands later; + // these tests pin the server contract: next = (stored ∪ add) \ remove, + // remove wins on overlap, both-fields is rejected, and a delta-only patch + // drives the GitLab webhook sync with computed next/previous arrays. + it('applies a delta add to the stored selection', async () => { + await seedPersonalSelection([101, 202]); + const caller = await createCallerForUser(testUser.id); + + await caller.personalReviewAgent.patchReviewConfig({ + platform: 'github', + selectedRepositoryDelta: { add: [303], remove: [] }, + }); + + const stored = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 202, 303] }) + ); + }); + + it('applies a delta remove to the stored selection', async () => { + await seedPersonalSelection([101, 202, 303]); + const caller = await createCallerForUser(testUser.id); + + await caller.personalReviewAgent.patchReviewConfig({ + platform: 'github', + selectedRepositoryDelta: { add: [], remove: [202] }, + }); + + const stored = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 303] }) + ); + }); + + it('lets remove win over add on an overlapping delta', async () => { + await seedPersonalSelection([101, 202]); + const caller = await createCallerForUser(testUser.id); + + await caller.personalReviewAgent.patchReviewConfig({ + platform: 'github', + selectedRepositoryDelta: { add: [202, 303], remove: [202] }, + }); + + const stored = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 303] }) + ); + }); + + it('applies a delta add to an empty stored selection', async () => { + await seedPersonalSelection([]); + const caller = await createCallerForUser(testUser.id); + + await caller.personalReviewAgent.patchReviewConfig({ + platform: 'github', + selectedRepositoryDelta: { add: [505], remove: [] }, + }); + + const stored = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [505] })); + }); + + it('rejects a patch carrying both selectedRepositoryIds and selectedRepositoryDelta', async () => { + await seedPersonalSelection([101, 202]); + const caller = await createCallerForUser(testUser.id); + + await expect( + caller.personalReviewAgent.patchReviewConfig({ + platform: 'github', + selectedRepositoryIds: [101], + selectedRepositoryDelta: { add: [303], remove: [] }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + const stored = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 202] }) + ); + }); + + it('runs GitLab webhook sync with computed next/previous arrays on a delta-only patch', async () => { + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'gitlab', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: [101, 202], + review_memory_enabled: true, + review_analytics_enabled: true, + }, + is_enabled: false, + created_by: testUser.id, + }); + await db.insert(platform_integrations).values({ + owned_by_user_id: testUser.id, + platform: 'gitlab', + integration_type: 'oauth', + integration_status: 'active', + metadata: { + webhook_secret: 'webhook-secret', + gitlab_instance_url: 'https://gitlab.example.com', + configured_webhooks: {}, + }, + }); + mockGetValidGitLabToken.mockResolvedValue('gitlab-token'); + const caller = await createCallerForUser(testUser.id); + + await caller.personalReviewAgent.patchReviewConfig({ + platform: 'gitlab', + selectedRepositoryDelta: { add: [303], remove: [101] }, + }); + + expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith( + 'gitlab-token', + 'webhook-secret', + [202, 303], + [101, 202], + {}, + 'https://gitlab.example.com' + ); + }); }); // ============================================================================ diff --git a/apps/web/src/routers/code-reviews-router.ts b/apps/web/src/routers/code-reviews-router.ts index ef5bf161cf..415a8c4813 100644 --- a/apps/web/src/routers/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews-router.ts @@ -133,6 +133,13 @@ const PatchReviewConfigInputSchema = z.object({ .optional(), repositorySelectionMode: z.enum(['all', 'selected']).optional(), selectedRepositoryIds: z.array(z.number()).optional(), + // Compatibility: selectedRepositoryIds full-array input kept for web save and mobile clients before delta support; remove when web and all shipped mobile clients send deltas. + selectedRepositoryDelta: z + .object({ + add: z.array(z.number()).max(500), + remove: z.array(z.number()).max(500), + }) + .optional(), manuallyAddedRepositories: z.array(ManuallyAddedRepositoryInputSchema).optional(), repositoryModelOverrides: z .array(RepositoryModelOverrideInputSchema) @@ -142,8 +149,9 @@ const PatchReviewConfigInputSchema = z.object({ disableReviewMd: z.boolean().optional(), gateThreshold: z.enum(['off', 'all', 'warning', 'critical']).optional(), // GitLab-specific: auto-configure webhooks. Only consulted when - // `selectedRepositoryIds` is also present in the patch (we only re-sync - // webhooks in that case, matching the full-save gating). + // `selectedRepositoryIds` or `selectedRepositoryDelta` is also present in + // the patch (we only re-sync webhooks in that case, matching the + // full-save gating). autoConfigureWebhooks: z.boolean().optional(), }); @@ -453,9 +461,9 @@ export const personalReviewAgentRouter = createTRPCRouter({ * * Platform forcing from the full save is re-applied post-merge (GitLab * forces `repository_selection_mode = 'selected'`). GitLab webhook sync - * runs ONLY when `selectedRepositoryIds` is present in the patch, so an - * unrelated edit (e.g. `modelSlug` only) never touches integration - * metadata. + * runs ONLY when `selectedRepositoryIds` or `selectedRepositoryDelta` is + * present in the patch, so an unrelated edit (e.g. `modelSlug` only) + * never touches integration metadata. * * `preserveCodeReviewFeatureSettings: true` keeps `review_memory_enabled` * and `review_analytics_enabled` on the row even when the patch supplies @@ -471,6 +479,16 @@ export const personalReviewAgentRouter = createTRPCRouter({ const owner = { type: 'user' as const, id: ctx.user.id, userId: ctx.user.id }; const platform = input.platform; + if ( + input.selectedRepositoryIds !== undefined && + input.selectedRepositoryDelta !== undefined + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Send either selectedRepositoryIds or selectedRepositoryDelta, not both.', + }); + } + const previousConfig = await getAgentConfigForOwner(owner, 'code_review', platform); if (!previousConfig) { throw new TRPCError({ @@ -484,6 +502,18 @@ export const personalReviewAgentRouter = createTRPCRouter({ | Array | undefined) || []; + // Delta repository selection: `selectedRepositoryDelta` computes + // next = (stored ∪ add) \ remove, with remove winning on overlap. + // The full-array `selectedRepositoryIds` input is kept for + // compatibility and flows through the merge helper unchanged. + let selectedRepositoryIdsFromDelta: Array | undefined; + if (input.selectedRepositoryDelta !== undefined) { + const remove = new Set(input.selectedRepositoryDelta.remove); + selectedRepositoryIdsFromDelta = [ + ...new Set([...previousRepoIds, ...input.selectedRepositoryDelta.add]), + ].filter(repositoryId => !remove.has(repositoryId)); + } + // Convert the stored snake_case config to a camelCase snapshot for // the merge helper. Mirrors the field mapping used in // `getReviewConfig` so a round-trip PATCH is a no-op on the read @@ -511,11 +541,18 @@ export const personalReviewAgentRouter = createTRPCRouter({ // Field-merge: every key absent from `input` is preserved from // `stored`. `null` is an explicit "clear" (e.g. customInstructions). // Council-related keys aren't accepted by the personal input schema - // so they can never reach this handler. - const { platform: _ignored, ...rest } = input; + // so they can never reach this handler. `selectedRepositoryDelta` is + // stripped — the patch helper only accepts known config keys; the + // delta is applied separately above. + const { platform: _ignored, selectedRepositoryDelta: _delta, ...rest } = input; const patch: CodeReviewFieldMergePatch = rest; const merged = applyCodeReviewConfigPatch(stored, patch); + // Effective selected repository ids: a delta input computes the next + // array above; a full-array input flows through the merge helper. + const effectiveSelectedRepositoryIds: Array = + selectedRepositoryIdsFromDelta ?? merged.selectedRepositoryIds ?? []; + // Re-apply platform forcing post-merge. GitLab only supports // 'selected' repo mode server-side, so an omitted or 'all' // repositorySelectionMode is clamped to 'selected' here. The full @@ -547,7 +584,7 @@ export const personalReviewAgentRouter = createTRPCRouter({ thinking_effort: merged.thinkingEffort ?? null, gate_threshold: merged.gateThreshold ?? 'off', repository_selection_mode: repositorySelectionMode, - selected_repository_ids: (merged.selectedRepositoryIds ?? []).filter( + selected_repository_ids: effectiveSelectedRepositoryIds.filter( (repositoryId): repositoryId is number => typeof repositoryId === 'number' ), manually_added_repositories: merged.manuallyAddedRepositories ?? [], @@ -566,14 +603,15 @@ export const personalReviewAgentRouter = createTRPCRouter({ }); // GitLab webhook sync runs ONLY when the patch actually carries - // `selectedRepositoryIds`. A patch that doesn't touch selection - // (e.g. mobile updating `focusAreas`) must not mutate integration - // metadata. Auto-configure is honored when present, defaulting to - // true to match the full-save default. + // `selectedRepositoryIds` or `selectedRepositoryDelta`. A patch that + // doesn't touch selection (e.g. mobile updating `focusAreas`) must + // not mutate integration metadata. Auto-configure is honored when + // present, defaulting to true to match the full-save default. let webhookSyncResult = null; if ( isGitLab && - input.selectedRepositoryIds !== undefined && + (input.selectedRepositoryIds !== undefined || + input.selectedRepositoryDelta !== undefined) && (input.autoConfigureWebhooks ?? true) && repositorySelectionMode === 'selected' ) { @@ -592,7 +630,7 @@ export const personalReviewAgentRouter = createTRPCRouter({ userId: ctx.user.id, }); - const selectedRepositoryIds = (input.selectedRepositoryIds ?? []).filter( + const selectedRepositoryIds = effectiveSelectedRepositoryIds.filter( (repositoryId): repositoryId is number => typeof repositoryId === 'number' ); const previousSelectedRepositoryIds = previousRepoIds.filter( diff --git a/apps/web/src/routers/code-reviews/code-reviews-router.ts b/apps/web/src/routers/code-reviews/code-reviews-router.ts index 5172891e4e..77fd2c0924 100644 --- a/apps/web/src/routers/code-reviews/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews/code-reviews-router.ts @@ -47,7 +47,6 @@ import { CancelCodeReviewInputSchema, RetriggerCodeReviewInputSchema, type Owner, - type ListCodeReviewsResponse, } from '@/lib/code-reviews/core'; import { DEFAULT_LIST_LIMIT } from '@/lib/code-reviews/core/constants'; import { selectedModelFromReviewSources } from '@/lib/code-reviews/core/model-selection'; @@ -72,6 +71,7 @@ import { shouldPublishCodeReviewToProvider, } from '@/lib/code-reviews/manual-config'; import { isLocalCodeReviewDevelopmentEnabled } from '@/lib/config.server'; +import { settleCodeReviewLedgerRow } from '@/lib/code-reviews/code-review-ledger'; /** * Re-creates the PR gate check (GitHub Check Run / GitLab commit status) @@ -248,8 +248,30 @@ export const codeReviewRouter = createTRPCRouter({ }), ]); - const response: ListCodeReviewsResponse = { - reviews, + // Role-gated DTO: raw ledger/transaction identifiers are admin+ only. + // Per-field decision: + // - session_id: internal cloud agent session id -> null for non-admin. + // - cli_session_id: internal CLI session id used to look up the billing + // ledger (getSessionUsageFromBilling) -> null for non-admin. + // - dispatch_reservation_id: internal dispatch reservation id -> null + // for non-admin. + // - check_run_id: internal GitHub Check Run ID -> null for non-admin. + // total_cost_musd (display cost) is retained: the mobile review detail + // screen renders it (review-detail-screen.tsx). + const callerRole = await ensureOrganizationAccess(ctx, fullInput.organizationId); + const canSeeRawIds = callerRole === 'owner' || callerRole === 'admin'; + const visibleReviews = canSeeRawIds + ? reviews + : reviews.map(review => ({ + ...review, + session_id: null, + cli_session_id: null, + dispatch_reservation_id: null, + check_run_id: null, + })); + + const response = { + reviews: visibleReviews, total, hasMore: offset + reviews.length < total, }; @@ -295,7 +317,7 @@ export const codeReviewRouter = createTRPCRouter({ }), ]); - const response: ListCodeReviewsResponse = { + const response = { reviews, total, hasMore: offset + reviews.length < total, @@ -325,9 +347,11 @@ export const codeReviewRouter = createTRPCRouter({ } // Authorization check based on owner type + let canSeeRawIds = true; if (review.owned_by_organization_id) { // Organization review: verify user is org member - await ensureOrganizationAccess(ctx, review.owned_by_organization_id); + const callerRole = await ensureOrganizationAccess(ctx, review.owned_by_organization_id); + canSeeRawIds = callerRole === 'owner' || callerRole === 'admin'; } else if (review.owned_by_user_id) { // Personal review: verify user owns it if (review.owned_by_user_id !== ctx.user.id) { @@ -403,9 +427,34 @@ export const codeReviewRouter = createTRPCRouter({ } } + // Role-gated DTO: raw ledger/transaction identifiers are admin+ only, + // matching listForOrganization. Non-owner/non-admin org members get nulls. + const visibleReview = canSeeRawIds + ? review + : { + ...review, + session_id: null, + cli_session_id: null, + dispatch_reservation_id: null, + check_run_id: null, + }; + const visibleAttempts = canSeeRawIds + ? attempts + : attempts.map(attempt => ({ + ...attempt, + session_id: null, + cli_session_id: null, + execution_id: null, + })); + return successResult({ - review: { ...review, council_result, model: selectedModel ?? review.model }, - attempts, + review: { + ...visibleReview, + rawIdsRedacted: !canSeeRawIds, + council_result, + model: selectedModel ?? review.model, + }, + attempts: visibleAttempts, tokenUsage, }); } catch (error) { @@ -486,6 +535,12 @@ export const codeReviewRouter = createTRPCRouter({ } ); await cancelCodeReview(input.reviewId); + await settleCodeReviewLedgerRow({ + reviewId: input.reviewId, + status: 'cancelled', + terminalReason: review.terminal_reason, + triggerSource: review.trigger_source, + }); try { await cancelPRGateCheck(review, credentialActor); } catch (gateError) { @@ -502,6 +557,12 @@ export const codeReviewRouter = createTRPCRouter({ if (review.status === 'queued' && !review.session_id) { console.error('Worker cancel failed, updating DB directly:', workerError); await cancelCodeReview(input.reviewId); + await settleCodeReviewLedgerRow({ + reviewId: input.reviewId, + status: 'cancelled', + terminalReason: review.terminal_reason, + triggerSource: review.trigger_source, + }); try { await cancelPRGateCheck(review, credentialActor); } catch (gateError) { @@ -516,6 +577,12 @@ export const codeReviewRouter = createTRPCRouter({ // For pending reviews (not yet dispatched to worker), update DB and finalize gate await cancelCodeReview(input.reviewId); + await settleCodeReviewLedgerRow({ + reviewId: input.reviewId, + status: 'cancelled', + terminalReason: review.terminal_reason, + triggerSource: review.trigger_source, + }); try { await cancelPRGateCheck(review, credentialActor); } catch (gateError) { diff --git a/apps/web/src/routers/code-reviews/review-memory-router.test.ts b/apps/web/src/routers/code-reviews/review-memory-router.test.ts new file mode 100644 index 0000000000..c2bafddf94 --- /dev/null +++ b/apps/web/src/routers/code-reviews/review-memory-router.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { code_review_memory_proposals, kilocode_users } from '@kilocode/db/schema'; + +import { createCallerForUser } from '@/routers/test-utils'; + +// Keyset pagination contract for `listProposalsPage`: the cursor encodes the last +// row's `(updated_at, id)` and the list orders by `updated_at` desc with `id` +// desc as the deterministic tie-breaker. A mid-list cursor resumes exactly +// after the last row; the final page returns `nextCursor: null`. +describe('review memory listProposalsPage pagination', () => { + let userId: string; + + beforeEach(async () => { + const user = await insertTestUser(); + userId = user.id; + }); + + afterEach(async () => { + await db.delete(code_review_memory_proposals); + await db.delete(kilocode_users); + }); + + async function seedProposal(updatedAt: string, repoFullName: string) { + const [row] = await db + .insert(code_review_memory_proposals) + .values({ + owned_by_user_id: userId, + owned_by_organization_id: null, + platform: 'github', + repo_full_name: repoFullName, + status: 'open', + title: `Proposal ${repoFullName}`, + rationale: 'Rationale', + proposed_markdown: '## Guidance', + evidence: [], + created_at: updatedAt, + updated_at: updatedAt, + }) + .returning(); + if (!row) throw new Error('seed proposal failed'); + return row; + } + + it('resumes mid-list from the cursor and returns null at the end of the list', async () => { + await seedProposal('2026-06-05T00:00:00.000Z', 'acme/five'); + await seedProposal('2026-06-04T00:00:00.000Z', 'acme/four'); + await seedProposal('2026-06-03T00:00:00.000Z', 'acme/three'); + await seedProposal('2026-06-02T00:00:00.000Z', 'acme/two'); + await seedProposal('2026-06-01T00:00:00.000Z', 'acme/one'); + + const caller = await createCallerForUser(userId); + + const page1 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2 }); + expect(page1.proposals.map(proposal => proposal.repo_full_name)).toEqual([ + 'acme/five', + 'acme/four', + ]); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = await caller.reviewMemory.listProposalsPage({ + platform: 'github', + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.proposals.map(proposal => proposal.repo_full_name)).toEqual([ + 'acme/three', + 'acme/two', + ]); + expect(page2.nextCursor).not.toBeNull(); + + const page3 = await caller.reviewMemory.listProposalsPage({ + platform: 'github', + limit: 2, + cursor: page2.nextCursor!, + }); + expect(page3.proposals.map(proposal => proposal.repo_full_name)).toEqual(['acme/one']); + expect(page3.nextCursor).toBeNull(); + }); + + it('pages through same-timestamp rows with the id tie-breaker without skipping', async () => { + const sameTime = '2026-06-05T00:00:00.000Z'; + const a = await seedProposal(sameTime, 'acme/a'); + const b = await seedProposal(sameTime, 'acme/b'); + const c = await seedProposal(sameTime, 'acme/c'); + + const caller = await createCallerForUser(userId); + + const page1 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2 }); + const page2 = await caller.reviewMemory.listProposalsPage({ + platform: 'github', + limit: 2, + cursor: page1.nextCursor!, + }); + + const ids = [ + ...page1.proposals.map(proposal => proposal.id), + ...page2.proposals.map(proposal => proposal.id), + ]; + expect(ids).toHaveLength(3); + expect(new Set(ids)).toEqual(new Set([a.id, b.id, c.id])); + // PostgreSQL orders uuid columns by their canonical byte form, which for + // lowercase RFC 4122 UUIDs equals plain string comparison. + const expected = [a.id, b.id, c.id].sort((x, y) => (x < y ? 1 : x > y ? -1 : 0)); + expect(ids).toEqual(expected); + expect(page2.nextCursor).toBeNull(); + }); + + it('pages through same-millisecond rows with microsecond precision without skipping', async () => { + // Two rows share a millisecond but differ in microseconds. The cursor must + // carry the exact sort key, or the second row is silently skipped. + const a = await seedProposal('2026-06-05T00:00:00.000123Z', 'acme/micro-a'); + const b = await seedProposal('2026-06-05T00:00:00.000100Z', 'acme/micro-b'); + + const caller = await createCallerForUser(userId); + + const page1 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 1 }); + expect(page1.proposals.map(proposal => proposal.id)).toEqual([a.id]); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = await caller.reviewMemory.listProposalsPage({ + platform: 'github', + limit: 1, + cursor: page1.nextCursor!, + }); + expect(page2.proposals.map(proposal => proposal.id)).toEqual([b.id]); + expect(page2.nextCursor).toBeNull(); + }); + + it('rejects a malformed cursor with BAD_REQUEST', async () => { + const caller = await createCallerForUser(userId); + + await expect( + caller.reviewMemory.listProposalsPage({ platform: 'github', cursor: 'not-a-cursor' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + await expect( + caller.reviewMemory.listProposalsPage({ + platform: 'github', + cursor: '2026-06-05T00:00:00.000Z|not-a-uuid', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('keeps the deployed array shape on listProposals for old clients', async () => { + await seedProposal('2026-06-05T00:00:00.000Z', 'acme/five'); + await seedProposal('2026-06-04T00:00:00.000Z', 'acme/four'); + + const caller = await createCallerForUser(userId); + + const proposals = await caller.reviewMemory.listProposals({ platform: 'github', limit: 2 }); + expect(Array.isArray(proposals)).toBe(true); + expect(proposals.map(proposal => proposal.repo_full_name)).toEqual(['acme/five', 'acme/four']); + }); +}); diff --git a/apps/web/src/routers/code-reviews/review-memory-router.ts b/apps/web/src/routers/code-reviews/review-memory-router.ts index dd48bab809..7eb302d97a 100644 --- a/apps/web/src/routers/code-reviews/review-memory-router.ts +++ b/apps/web/src/routers/code-reviews/review-memory-router.ts @@ -6,6 +6,7 @@ import { runReviewMemoryAnalysis } from '@/lib/code-reviews/review-memory/aggreg import { countActiveProposals, listProposals, + listProposalsPage, listRepositoriesWithRecentFeedback, rejectProposal, updateProposal, @@ -84,6 +85,29 @@ export const reviewMemoryRouter = createTRPCRouter({ }); }), + // Compatibility: `listProposals` above keeps the deployed array shape for + // the web panel and stale client bundles. The paginated shape is additive. + listProposalsPage: baseProcedure + .input( + PlatformOwnerInputSchema.extend({ + repoFullName: z.string().min(1).optional(), + statuses: z.array(ProposalStatusSchema).optional(), + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const owner = await ownerFromInput(ctx, input); + return await listProposalsPage({ + owner, + platform: input.platform, + repoFullName: input.repoFullName, + statuses: input.statuses, + limit: input.limit, + cursor: input.cursor, + }); + }), + setEnabled: baseProcedure .input(PlatformOwnerInputSchema.extend({ enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts index f1e650f280..40746f2786 100644 --- a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts +++ b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts @@ -1,10 +1,37 @@ +const mockSyncWebhooksForRepositories = jest.fn(); +const mockGetValidGitLabToken = jest.fn(); +const mockGetBitbucketCodeReviewerReadiness = jest.fn(); + +jest.mock('@/lib/integrations/platforms/gitlab/webhook-sync', () => ({ + syncWebhooksForRepositories: (...args: unknown[]) => mockSyncWebhooksForRepositories(...args), +})); + +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (...args: unknown[]) => mockGetValidGitLabToken(...args), +})); + +jest.mock('@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketCodeReviewerReadiness: (...args: unknown[]) => + mockGetBitbucketCodeReviewerReadiness(...args), +})); + +// NOTE: `jest` is intentionally NOT imported from '@jest/globals' here. The +// @swc/jest transform only hoists `jest.mock(...)` above the static imports +// when `jest` is the global binding; importing it as a local binding disables +// that hoist, so the mocks below would register AFTER `createCallerForUser` +// pulls in the real gitlab-service. Using global `jest` keeps the mocks hoisted. import { afterAll, describe, expect, it } from '@jest/globals'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { getAgentConfig } from '@/lib/agent-config/db/agent-configs'; import { db } from '@/lib/drizzle'; -import { agent_configs, organization_audit_logs, organizations } from '@kilocode/db/schema'; +import { + agent_configs, + organization_audit_logs, + organizations, + platform_integrations, +} from '@kilocode/db/schema'; import { and, eq } from 'drizzle-orm'; const createdOrganizationIds: string[] = []; @@ -452,6 +479,330 @@ describe('organization review agent router: patchReviewConfig', () => { }) ); }); + + // P2-GH-45c: delta repository save on the org surface. Mirrors the + // personal contract: next = (stored ∪ add) \ remove, remove wins on + // overlap, and a both-fields patch is rejected. + async function seedOrgSelection( + organization: { id: string }, + owner: { id: string }, + selectedRepositoryIds: Array + ): Promise { + await db.insert(agent_configs).values({ + owned_by_organization_id: organization.id, + agent_type: 'code_review', + platform: 'github', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: selectedRepositoryIds, + }, + is_enabled: false, + created_by: owner.id, + }); + } + + it('applies a delta add to the stored org selection', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgSelection(organization, owner, [101, 202]); + const caller = await createCallerForUser(owner.id); + + await caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'github', + selectedRepositoryDelta: { add: [303], remove: [] }, + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'github'); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 202, 303] }) + ); + }); + + it('lets remove win over add on an overlapping org delta', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgSelection(organization, owner, [101, 202]); + const caller = await createCallerForUser(owner.id); + + await caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'github', + selectedRepositoryDelta: { add: [202, 303], remove: [202] }, + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'github'); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 303] }) + ); + }); + + it('applies a delta add to an empty stored org selection', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgSelection(organization, owner, []); + const caller = await createCallerForUser(owner.id); + + await caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'github', + selectedRepositoryDelta: { add: [505], remove: [] }, + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'github'); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [505] })); + }); + + it('rejects an org patch carrying both selectedRepositoryIds and selectedRepositoryDelta', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgSelection(organization, owner, [101, 202]); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'github', + selectedRepositoryIds: [101], + selectedRepositoryDelta: { add: [303], remove: [] }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'github'); + expect(stored?.config).toEqual( + expect.objectContaining({ selected_repository_ids: [101, 202] }) + ); + }); + + // P2-GH-45c: the org webhook-sync delta path. A delta-only GitLab patch + // must drive syncWebhooksForRepositories with the computed next array + // ((stored ∪ add) \ remove) and the previous stored array. + it('runs GitLab webhook sync with computed next/previous arrays on a delta-only patch', async () => { + const { owner, organization } = await createFixtureOrganization(); + await db.insert(agent_configs).values({ + owned_by_organization_id: organization.id, + agent_type: 'code_review', + platform: 'gitlab', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: [101, 202], + review_memory_enabled: true, + review_analytics_enabled: true, + }, + is_enabled: false, + created_by: owner.id, + }); + await db.insert(platform_integrations).values({ + owned_by_organization_id: organization.id, + platform: 'gitlab', + integration_type: 'oauth', + integration_status: 'active', + metadata: { + webhook_secret: 'webhook-secret', + gitlab_instance_url: 'https://gitlab.example.com', + configured_webhooks: {}, + }, + }); + mockGetValidGitLabToken.mockResolvedValue('gitlab-token'); + mockSyncWebhooksForRepositories.mockResolvedValue({ + result: { created: [], updated: [], deleted: [], errors: [] }, + updatedWebhooks: {}, + }); + const caller = await createCallerForUser(owner.id); + + await caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'gitlab', + selectedRepositoryDelta: { add: [303], remove: [101] }, + }); + + expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith( + 'gitlab-token', + 'webhook-secret', + [202, 303], + [101, 202], + {}, + 'https://gitlab.example.com' + ); + }); + + afterAll(async () => { + for (const organizationId of createdOrganizationIds) { + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_organization_id, organizationId)); + } + }); +}); + +describe('organization review agent router: patchReviewConfig Bitbucket validation', () => { + const REPO_A = '11111111-1111-4111-8111-111111111111'; + const REPO_B = '22222222-2222-4222-9222-222222222222'; + const UNKNOWN_REPO = '99999999-9999-4999-a999-999999999999'; + + function bitbucketReadiness( + overrides: { + repositoryCache?: { + status: 'available' | 'uninitialized' | 'temporarily_unavailable'; + repositories: Array<{ id: string; fullName: string }>; + syncedAt: string | null; + }; + } = {} + ) { + return { + connected: true, + ready: true, + integrationId: 'integration-id', + workspace: { + uuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + slug: 'acme', + displayName: 'Acme Workspace', + }, + missingRequiredScopes: [], + repositoryCache: { + status: 'available' as const, + repositories: [ + { id: REPO_A, fullName: 'acme/api' }, + { id: REPO_B, fullName: 'acme/web' }, + ], + syncedAt: '2026-06-24T08:00:00.000Z', + ...overrides.repositoryCache, + }, + }; + } + + async function seedOrgBitbucketConfig( + organization: { id: string }, + owner: { id: string }, + selectedRepositoryIds: string[] + ): Promise { + await db.insert(agent_configs).values({ + owned_by_organization_id: organization.id, + agent_type: 'code_review', + platform: 'bitbucket', + config: { + review_style: 'balanced', + focus_areas: [], + model_slug: 'test-model', + repository_selection_mode: 'selected', + selected_repository_ids: selectedRepositoryIds, + gate_threshold: 'off', + disable_review_md: true, + manually_added_repositories: [], + council: null, + council_enabled_repository_ids: [], + }, + is_enabled: false, + created_by: owner.id, + }); + } + + afterAll(async () => { + for (const organizationId of createdOrganizationIds) { + await db + .delete(agent_configs) + .where(eq(agent_configs.owned_by_organization_id, organizationId)); + await db.delete(organizations).where(eq(organizations.id, organizationId)); + } + }); + + it('rejects a Bitbucket delta add with a repository UUID missing from the cache', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgBitbucketConfig(organization, owner, [REPO_A]); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue(bitbucketReadiness()); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'bitbucket', + selectedRepositoryDelta: { + add: [UNKNOWN_REPO], + remove: [], + }, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: + 'Every selected Bitbucket repository must exactly match the current repository cache', + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'bitbucket'); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [REPO_A] })); + }); + + it('rejects a Bitbucket delta that removes the last selected repository', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgBitbucketConfig(organization, owner, [REPO_A]); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue(bitbucketReadiness()); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'bitbucket', + selectedRepositoryDelta: { add: [], remove: [REPO_A] }, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Select at least one cached Bitbucket repository', + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'bitbucket'); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [REPO_A] })); + }); + + it('rejects duplicate Bitbucket repository IDs in a full-array patch', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgBitbucketConfig(organization, owner, [REPO_A]); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue(bitbucketReadiness()); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'bitbucket', + selectedRepositoryIds: [REPO_A, REPO_A], + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Bitbucket repository selections must be unique', + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'bitbucket'); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [REPO_A] })); + }); + + it('rejects a Bitbucket delta when the repository cache is unavailable', async () => { + const { owner, organization } = await createFixtureOrganization(); + await seedOrgBitbucketConfig(organization, owner, [REPO_A]); + mockGetBitbucketCodeReviewerReadiness.mockResolvedValue( + bitbucketReadiness({ + repositoryCache: { + status: 'temporarily_unavailable', + repositories: [], + syncedAt: null, + }, + }) + ); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.reviewAgent.patchReviewConfig({ + organizationId: organization.id, + platform: 'bitbucket', + selectedRepositoryDelta: { add: [REPO_B], remove: [] }, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: 'Refresh the Bitbucket repository cache before configuring Code Reviewer', + }); + + const stored = await getAgentConfig(organization.id, 'code_review', 'bitbucket'); + expect(stored?.config).toEqual(expect.objectContaining({ selected_repository_ids: [REPO_A] })); + }); }); describe('organization review agent router: skip bot pull requests', () => { diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.ts index 1bad7ea799..4fa4e0730e 100644 --- a/apps/web/src/routers/organizations/organization-code-reviews-router.ts +++ b/apps/web/src/routers/organizations/organization-code-reviews-router.ts @@ -173,6 +173,13 @@ const PatchReviewConfigInputSchema = OrganizationIdInputSchema.extend({ .optional(), repositorySelectionMode: z.enum(['all', 'selected']).optional(), selectedRepositoryIds: z.array(z.union([z.number(), z.string()])).optional(), + // Compatibility: selectedRepositoryIds full-array input kept for web save and mobile clients before delta support; remove when web and all shipped mobile clients send deltas. + selectedRepositoryDelta: z + .object({ + add: z.array(z.union([z.number(), z.string()])).max(500), + remove: z.array(z.union([z.number(), z.string()])).max(500), + }) + .optional(), manuallyAddedRepositories: z.array(ManuallyAddedRepositoryInputSchema).optional(), repositoryModelOverrides: z .array(RepositoryModelOverrideInputSchema) @@ -183,8 +190,8 @@ const PatchReviewConfigInputSchema = OrganizationIdInputSchema.extend({ gateThreshold: z.enum(['off', 'all', 'warning', 'critical']).optional(), council: CodeReviewCouncilConfigSchema.nullable().optional(), councilEnabledRepositoryIds: z.array(z.union([z.number(), z.string()])).optional(), - // GitLab-specific: only consulted when `selectedRepositoryIds` is also - // present in the patch. + // GitLab-specific: only consulted when `selectedRepositoryIds` or + // `selectedRepositoryDelta` is also present in the patch. autoConfigureWebhooks: z.boolean().optional(), }); @@ -811,16 +818,14 @@ export const organizationReviewAgentRouter = createTRPCRouter({ * - GitLab forces `repository_selection_mode = 'selected'` * - Bitbucket forces 'selected' + `gate_threshold = 'off'` + * `disable_review_md = true` + `manually_added_repositories = []` - * The PATCH intentionally does NOT re-validate Bitbucket selections - * against the workspace cache (that's a save-level concern handled by - * the full save) and does NOT ensure the Bitbucket workspace webhook - * (also save-level). Callers that change `selectedRepositoryIds` for - * Bitbucket via PATCH are expected to have already saved a valid - * configuration. + * The PATCH re-validates Bitbucket selections against the workspace + * repository cache whenever the patch carries `selectedRepositoryIds` or + * `selectedRepositoryDelta`, mirroring the full save. It does NOT ensure + * the Bitbucket workspace webhook (that stays save-level). * - * GitLab webhook sync runs ONLY when `selectedRepositoryIds` is present - * in the patch, so an unrelated edit (e.g. `focusAreas` only) never - * touches integration metadata. + * GitLab webhook sync runs ONLY when `selectedRepositoryIds` or + * `selectedRepositoryDelta` is present in the patch, so an unrelated edit + * (e.g. `focusAreas` only) never touches integration metadata. * * The council entitlement gate (`isCouncilActive + isCouncilEntitledForOrganization`) * fires ONLY when the patch actually carries a `council` key. An omitted @@ -839,6 +844,16 @@ export const organizationReviewAgentRouter = createTRPCRouter({ const isBitbucket = platform === PLATFORM.BITBUCKET; const isGitLab = platform === PLATFORM.GITLAB; + if ( + input.selectedRepositoryIds !== undefined && + input.selectedRepositoryDelta !== undefined + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Send either selectedRepositoryIds or selectedRepositoryDelta, not both.', + }); + } + const previousConfig = await getAgentConfig(input.organizationId, 'code_review', platform); if (!previousConfig) { throw new TRPCError({ @@ -852,6 +867,18 @@ export const organizationReviewAgentRouter = createTRPCRouter({ | Array | undefined) || []; + // Delta repository selection: `selectedRepositoryDelta` computes + // next = (stored ∪ add) \ remove, with remove winning on overlap. + // The full-array `selectedRepositoryIds` input is kept for + // compatibility and flows through the merge helper unchanged. + let selectedRepositoryIdsFromDelta: Array | undefined; + if (input.selectedRepositoryDelta !== undefined) { + const remove = new Set(input.selectedRepositoryDelta.remove); + selectedRepositoryIdsFromDelta = [ + ...new Set([...previousRepoIds, ...input.selectedRepositoryDelta.add]), + ].filter(repositoryId => !remove.has(repositoryId)); + } + const prevCfg = previousConfig.config as CodeReviewAgentConfig; const stored: CodeReviewStoredConfig = { reviewStyle: prevCfg.review_style || 'balanced', @@ -885,10 +912,22 @@ export const organizationReviewAgentRouter = createTRPCRouter({ // Field-merge: every key absent from `input` is preserved from // `stored`. `null` is an explicit "clear" (e.g. `council: null`). - const { organizationId: _orgId, platform: _platform, ...rest } = input; + // `selectedRepositoryDelta` is stripped — the patch helper only + // accepts known config keys; the delta is applied separately above. + const { + organizationId: _orgId, + platform: _platform, + selectedRepositoryDelta: _delta, + ...rest + } = input; const patch: CodeReviewFieldMergePatch = rest; const merged = applyCodeReviewConfigPatch(stored, patch); + // Effective selected repository ids: a delta input computes the next + // array above; a full-array input flows through the merge helper. + const effectiveSelectedRepositoryIds: Array = + selectedRepositoryIdsFromDelta ?? merged.selectedRepositoryIds ?? []; + // Council entitlement gate: ONLY when the patch actually carries a // `council` key. An omitted council must not re-trigger the gate — // a non-entitled org keeps its existing (un)set council untouched. @@ -957,6 +996,25 @@ export const organizationReviewAgentRouter = createTRPCRouter({ thinking_effort: override.thinkingEffort ?? null, })); + // Bitbucket selections must be re-validated against the workspace + // repository cache before persisting, exactly like the full save. A + // delta (or full-array) input that would leave an invalid selection — + // unknown UUIDs, an empty result after removal, duplicates, or an + // unavailable cache — is rejected here rather than persisted. + if ( + isBitbucket && + (input.selectedRepositoryIds !== undefined || input.selectedRepositoryDelta !== undefined) + ) { + const readiness = await getBitbucketCodeReviewerReadiness(input.organizationId); + requireBitbucketRepositorySelection( + { + repositorySelectionMode, + selectedRepositoryIds: effectiveSelectedRepositoryIds, + }, + readiness + ); + } + await upsertAgentConfig({ organizationId: input.organizationId, agentType: 'code_review', @@ -969,7 +1027,7 @@ export const organizationReviewAgentRouter = createTRPCRouter({ thinking_effort: merged.thinkingEffort ?? null, gate_threshold: gateThreshold, repository_selection_mode: repositorySelectionMode, - selected_repository_ids: (merged.selectedRepositoryIds ?? []) as Array, + selected_repository_ids: effectiveSelectedRepositoryIds as Array, manually_added_repositories: manuallyAddedRepositories, repository_model_overrides: repositoryModelOverrides, council, @@ -989,14 +1047,15 @@ export const organizationReviewAgentRouter = createTRPCRouter({ }); // GitLab webhook sync runs ONLY when the patch actually carries - // `selectedRepositoryIds`. A patch that doesn't touch selection - // (e.g. mobile updating `focusAreas`) must not mutate integration - // metadata. Auto-configure is honored when present, defaulting to - // true to match the full-save default. + // `selectedRepositoryIds` or `selectedRepositoryDelta`. A patch that + // doesn't touch selection (e.g. mobile updating `focusAreas`) must + // not mutate integration metadata. Auto-configure is honored when + // present, defaulting to true to match the full-save default. let webhookSyncResult = null; if ( isGitLab && - input.selectedRepositoryIds !== undefined && + (input.selectedRepositoryIds !== undefined || + input.selectedRepositoryDelta !== undefined) && (input.autoConfigureWebhooks ?? true) && repositorySelectionMode === 'selected' ) { @@ -1019,7 +1078,7 @@ export const organizationReviewAgentRouter = createTRPCRouter({ organizationId: input.organizationId, }); - const selectedRepositoryIds = (input.selectedRepositoryIds ?? []).filter( + const selectedRepositoryIds = effectiveSelectedRepositoryIds.filter( (repositoryId): repositoryId is number => typeof repositoryId === 'number' ); const previousSelectedRepositoryIds = previousRepoIds.filter( diff --git a/apps/web/src/routers/organizations/organization-funds-router.test.ts b/apps/web/src/routers/organizations/organization-funds-router.test.ts index d32df25453..b71e05d248 100644 --- a/apps/web/src/routers/organizations/organization-funds-router.test.ts +++ b/apps/web/src/routers/organizations/organization-funds-router.test.ts @@ -8,11 +8,12 @@ import { } from '@kilocode/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; -import { createOrganization } from '@/lib/organizations/organizations'; +import { createOrganization, addUserToOrganization } from '@/lib/organizations/organizations'; import { hasOrganizationEverPaid } from '@/lib/creditTransactions'; import type { User, Organization } from '@kilocode/db/schema'; let ownerUser: User; +let memberUser: User; let parentOrg: Organization; let childA: Organization; let childB: Organization; @@ -60,11 +61,19 @@ describe('organization funds router', () => { is_admin: false, }); + memberUser = await insertTestUser({ + google_user_email: 'funds-member@example.com', + google_user_name: 'Funds Member', + is_admin: false, + }); + parentOrg = await createOrganization('Funds Parent Org', ownerUser.id); childA = await createOrganization('Funds Child A', ownerUser.id); childB = await createOrganization('Funds Child B', ownerUser.id); unrelatedOrg = await createOrganization('Funds Unrelated Org', ownerUser.id); + await addUserToOrganization(parentOrg.id, memberUser.id, 'member'); + await setChildOf(childA.id, parentOrg.id); await setChildOf(childB.id, parentOrg.id); }); @@ -297,4 +306,21 @@ describe('organization funds router', () => { expect(balanceOf(await getOrg(childA.id))).toBe(0); }); }); + + describe('role matrix', () => { + it('rejects member for every funds procedure', async () => { + const caller = await createCallerForUser(memberUser.id); + + await expect( + caller.organizations.funds.childBalances({ organizationId: parentOrg.id }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await expect( + caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [{ childOrganizationId: childA.id, amountMicrodollars: 1_000_000 }], + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); }); diff --git a/apps/web/src/routers/organizations/organization-router.test.ts b/apps/web/src/routers/organizations/organization-router.test.ts index e868469eb2..f689580675 100644 --- a/apps/web/src/routers/organizations/organization-router.test.ts +++ b/apps/web/src/routers/organizations/organization-router.test.ts @@ -1,6 +1,11 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { credit_transactions, organization_memberships, organizations } from '@kilocode/db/schema'; +import { + credit_transactions, + organization_invitations, + organization_memberships, + organizations, +} from '@kilocode/db/schema'; import { eq, inArray } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createOrganization, addUserToOrganization } from '@/lib/organizations/organizations'; @@ -355,6 +360,179 @@ describe('organizations trpc router', () => { }); }); + describe('withMembers role-gated DTO', () => { + it('returns the narrow member shape for members and the full shape for owners', async () => { + const owner = await insertTestUser({ + google_user_email: `dto-owner-${crypto.randomUUID()}@example.com`, + google_user_name: 'DTO Owner', + is_admin: false, + }); + const member = await insertTestUser({ + google_user_email: `dto-member-${crypto.randomUUID()}@example.com`, + google_user_name: 'DTO Member', + is_admin: false, + }); + const org = await createOrganization('DTO Org', owner.id); + await addUserToOrganization(org.id, member.id, 'member'); + await db + .update(organizations) + .set({ stripe_customer_id: 'cus_dto_org' }) + .where(eq(organizations.id, org.id)); + + const invitedEmail = `dto-invite-${crypto.randomUUID()}@example.com`; + await db.insert(organization_invitations).values({ + organization_id: org.id, + email: invitedEmail, + role: 'member', + invited_by: owner.id, + token: 'dto-invite-token', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + + try { + const memberCaller = await createCallerForUser(member.id); + const memberResult = await memberCaller.organizations.withMembers({ + organizationId: org.id, + }); + + expect(Object.keys(memberResult).sort()).toEqual( + [ + 'id', + 'name', + 'created_at', + 'updated_at', + 'microdollars_used', + 'microdollars_balance', + 'total_microdollars_acquired', + 'next_credit_expiration_at', + 'auto_top_up_enabled', + 'settings', + 'seat_count', + 'require_seats', + 'created_by_kilo_user_id', + 'deleted_at', + 'sso_domain', + 'parent_organization_id', + 'plan', + 'free_trial_end_at', + 'company_domain', + 'callerRole', + 'members', + 'childOrganizations', + 'effectiveSsoPolicy', + ].sort() + ); + expect(memberResult).not.toHaveProperty('stripe_customer_id'); + + const memberActive = memberResult.members.find( + m => m.status === 'active' && m.id === member.id + ); + expect(Object.keys(memberActive!).sort()).toEqual( + [ + 'id', + 'name', + 'email', + 'role', + 'status', + 'inviteDate', + 'dailyUsageLimitUsd', + 'childOrganizationMemberships', + ].sort() + ); + + const memberInvited = memberResult.members.find(m => m.status === 'invited'); + expect(Object.keys(memberInvited!).sort()).toEqual( + [ + 'email', + 'role', + 'status', + 'inviteDate', + 'inviteId', + 'emailStatus', + 'dailyUsageLimitUsd', + ].sort() + ); + expect(memberInvited).not.toHaveProperty('inviteToken'); + expect(memberInvited).not.toHaveProperty('inviteUrl'); + expect(memberInvited).not.toHaveProperty('currentDailyUsageUsd'); + + const ownerCaller = await createCallerForUser(owner.id); + const ownerResult = await ownerCaller.organizations.withMembers({ + organizationId: org.id, + }); + + expect(Object.keys(ownerResult).sort()).toEqual( + [ + 'id', + 'name', + 'created_at', + 'updated_at', + 'microdollars_used', + 'microdollars_balance', + 'total_microdollars_acquired', + 'next_credit_expiration_at', + 'stripe_customer_id', + 'auto_top_up_enabled', + 'settings', + 'seat_count', + 'require_seats', + 'created_by_kilo_user_id', + 'deleted_at', + 'sso_domain', + 'parent_organization_id', + 'plan', + 'free_trial_end_at', + 'company_domain', + 'callerRole', + 'members', + 'childOrganizations', + 'effectiveSsoPolicy', + ].sort() + ); + expect(ownerResult).toHaveProperty('stripe_customer_id', 'cus_dto_org'); + + const ownerActive = ownerResult.members.find( + m => m.status === 'active' && m.id === member.id + ); + expect(Object.keys(ownerActive!).sort()).toEqual( + [ + 'id', + 'name', + 'email', + 'role', + 'status', + 'inviteDate', + 'dailyUsageLimitUsd', + 'currentDailyUsageUsd', + 'childOrganizationMemberships', + ].sort() + ); + + const ownerInvited = ownerResult.members.find(m => m.status === 'invited'); + expect(Object.keys(ownerInvited!).sort()).toEqual( + [ + 'email', + 'role', + 'status', + 'inviteDate', + 'inviteToken', + 'inviteId', + 'inviteUrl', + 'emailStatus', + 'dailyUsageLimitUsd', + 'currentDailyUsageUsd', + ].sort() + ); + expect(ownerInvited).toHaveProperty('inviteToken', 'dto-invite-token'); + } finally { + await db + .delete(organization_invitations) + .where(eq(organization_invitations.organization_id, org.id)); + await db.delete(organizations).where(eq(organizations.id, org.id)); + } + }); + }); + describe('list procedure', () => { it('nests only inherited direct children under eligible direct memberships', async () => { const parentOwner = await insertTestUser({ diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 76c2e1675e..12c8b006bf 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -9,7 +9,10 @@ import { timedUsageQuery } from '@/lib/usage-query'; import { successResult } from '@/lib/maybe-result'; import { captureMessage } from '@sentry/nextjs'; import type { + MemberOrganizationWithMembers, + OrganizationSsoPolicyView, OrganizationWithMembers, + OrganizationWithMembersResponse, UserOrganizationWithInheritedChildren, } from '@/lib/organizations/organization-types'; import { @@ -312,7 +315,7 @@ export const organizationsRouter = createTRPCRouter({ withMembers: baseProcedure .input(OrganizationIdInputSchema) - .query(async opts => { + .query(async opts => { const organizationId = opts.input.organizationId; const callerRole = await ensureOrganizationAccess(opts.ctx, organizationId); @@ -402,26 +405,66 @@ export const organizationsRouter = createTRPCRouter({ }; }); + // Role-gated DTO: ordinary members must not receive the Stripe customer + // id or the invitation secret. Admin-and-above keep the full shape. + // + // Stripped for member: + // - organization.stripe_customer_id (payment identifier; rendered only in + // the admin dashboard, OrganizationInfoCard.tsx). + // - invited member inviteToken/inviteUrl (the accept-invite secret). + // - member currentDailyUsageUsd (no member-role consumer). + // + // Compatibility: inviteId, dailyUsageLimitUsd, emailStatus, and + // childOrganizationMemberships are kept for member-facing consumers + // (OrganizationMembersCard, mobile members screen); remove when those + // consumers migrate to organizations.members.listPublic. + const effectiveSsoPolicy: OrganizationSsoPolicyView = + ssoPolicy.status === 'required' + ? { + required: true, + source: ssoPolicy.source, + domain: ssoPolicy.domain, + configurationError: false, + } + : { + required: false, + source: null, + domain: null, + configurationError: ssoPolicy.status === 'misconfigured', + }; + + if (callerRole === 'member') { + const { stripe_customer_id: _stripeCustomerId, ...memberOrganization } = organization; + const memberPayload = membersWithChildOrganizations.map(member => { + if (member.status === 'active') { + const { currentDailyUsageUsd: _currentDailyUsageUsd, ...activeMember } = member; + return activeMember; + } + const { + inviteToken: _inviteToken, + inviteUrl: _inviteUrl, + currentDailyUsageUsd: _currentDailyUsageUsd, + ...invitedMember + } = member; + return invitedMember; + }); + + return { + ...memberOrganization, + callerRole, + members: memberPayload, + childOrganizations, + effectiveSsoPolicy, + } satisfies MemberOrganizationWithMembers; + } + return { ...organization, callerRole, members: membersWithChildOrganizations, childOrganizations, - effectiveSsoPolicy: - ssoPolicy.status === 'required' - ? { - required: true, - source: ssoPolicy.source, - domain: ssoPolicy.domain, - configurationError: false, - } - : { - required: false, - source: null, - domain: null, - configurationError: ssoPolicy.status === 'misconfigured', - }, - }; + effectiveSsoPolicy, + } satisfies OrganizationWithMembers; }), createChild: organizationAdminMutationProcedure diff --git a/apps/web/src/routers/organizations/organization-security-agent-router.ts b/apps/web/src/routers/organizations/organization-security-agent-router.ts index 61e5ac2544..968d607af8 100644 --- a/apps/web/src/routers/organizations/organization-security-agent-router.ts +++ b/apps/web/src/routers/organizations/organization-security-agent-router.ts @@ -79,6 +79,9 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ getCommandStatus: organizationMemberProcedure .input(OrganizationIdInputSchema.merge(handlers.getCommandStatus.inputSchema)) .query(handlers.getCommandStatus.handler), + getCommandStatuses: organizationMemberProcedure + .input(OrganizationIdInputSchema.merge(handlers.getCommandStatuses.inputSchema)) + .query(handlers.getCommandStatuses.handler), listActiveCommands: organizationMemberProcedure.query(handlers.listActiveCommands), getOrphanedRepositories: organizationMemberProcedure.query(handlers.getOrphanedRepositories), deleteFindingsByRepository: organizationBillingMutationProcedure diff --git a/apps/web/src/routers/organizations/security-agent-role-matrix.test.ts b/apps/web/src/routers/organizations/security-agent-role-matrix.test.ts new file mode 100644 index 0000000000..f75c274f5f --- /dev/null +++ b/apps/web/src/routers/organizations/security-agent-role-matrix.test.ts @@ -0,0 +1,401 @@ +import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { NextRequest } from 'next/server'; +import { getUserFromAuth } from '@/lib/user/server'; +import { connectWithPAT } from '@/lib/integrations/gitlab-service'; +import { + buildGitLabOAuthUrl, + calculateTokenExpiry, + exchangeGitLabOAuthCode, + fetchGitLabProjects, + fetchGitLabUser, +} from '@/lib/integrations/platforms/gitlab/adapter'; +import { storeGitLabOAuthIntegration } from '@/lib/integrations/platforms/gitlab/oauth-integration-writer'; +import { createGitLabOAuthState } from '@/lib/integrations/platforms/gitlab/oauth-state'; +import { + handleGitLabOAuthConnect, + handleGitLabOAuthConnectPost, +} from '@/lib/integrations/oauth/platforms/gitlab-connect'; +import { handleGitLabOAuthCallback } from '@/lib/integrations/oauth/platforms/gitlab-callback'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createTestOrganization } from '@/tests/helpers/organization.helper'; +import { addUserToOrganization } from '@/lib/organizations/organizations'; +import { db } from '@/lib/drizzle'; +import { platform_integrations, type Organization, type User } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; + +jest.mock('@/lib/user/server', () => ({ + getUserFromAuth: jest.fn(), +})); +jest.mock('@/lib/integrations/gitlab-service', () => ({ + ...jest.requireActual('@/lib/integrations/gitlab-service'), + connectWithPAT: jest.fn(), +})); +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + ...jest.requireActual('@/lib/integrations/platforms/gitlab/adapter'), + buildGitLabOAuthUrl: jest.fn(), + exchangeGitLabOAuthCode: jest.fn(), + fetchGitLabUser: jest.fn(), + fetchGitLabProjects: jest.fn(), + calculateTokenExpiry: jest.fn(), +})); +jest.mock('@/lib/integrations/platforms/gitlab/oauth-credentials', () => ({ + storeGitLabOAuthCredentials: jest.fn(), + getGitLabOAuthCredentials: jest.fn(), +})); +jest.mock('@/lib/integrations/platforms/gitlab/oauth-integration-writer', () => ({ + storeGitLabOAuthIntegration: jest.fn(), +})); + +const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); +const mockedConnectWithPAT = jest.mocked(connectWithPAT); +const mockedBuildGitLabOAuthUrl = jest.mocked(buildGitLabOAuthUrl); +const mockedExchangeGitLabOAuthCode = jest.mocked(exchangeGitLabOAuthCode); +const mockedFetchGitLabUser = jest.mocked(fetchGitLabUser); +const mockedFetchGitLabProjects = jest.mocked(fetchGitLabProjects); +const mockedCalculateTokenExpiry = jest.mocked(calculateTokenExpiry); +const mockedStoreGitLabOAuthIntegration = jest.mocked(storeGitLabOAuthIntegration); + +const ROLE_KEYS = ['owner', 'admin', 'member', 'billing_manager', 'non_member'] as const; +type RoleKey = (typeof ROLE_KEYS)[number]; + +type Gate = 'member' | 'billing'; + +let organization: Organization; +let users: Record; +let callers: Record>>; + +beforeAll(async () => { + const owner = await insertTestUser(); + const admin = await insertTestUser(); + const member = await insertTestUser(); + const billingManager = await insertTestUser(); + const nonMember = await insertTestUser(); + + // require_seats=false grants the trial bypass that the billing mutation + // procedures need after their access check passes. + organization = await createTestOrganization( + `Security matrix ${crypto.randomUUID()}`, + owner.id, + 0, + {}, + false + ); + await addUserToOrganization(organization.id, admin.id, 'admin'); + await addUserToOrganization(organization.id, member.id, 'member'); + await addUserToOrganization(organization.id, billingManager.id, 'billing_manager'); + + users = { owner, admin, member, billing_manager: billingManager, non_member: nonMember }; + callers = { + owner: await createCallerForUser(owner.id), + admin: await createCallerForUser(admin.id), + member: await createCallerForUser(member.id), + billing_manager: await createCallerForUser(billingManager.id), + non_member: await createCallerForUser(nonMember.id), + }; +}); + +function makeRequest(pathWithQuery: string): NextRequest { + return new NextRequest(`http://localhost:3000${pathWithQuery}`); +} + +function expectRedirect(response: Response, expectedPathWithQuery: string): void { + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + const url = new URL(location ?? ''); + expect(`${url.pathname}${url.search}`).toBe(expectedPathWithQuery); +} + +async function seedGitLabIntegration(): Promise { + await db.insert(platform_integrations).values({ + owned_by_organization_id: organization.id, + platform: 'gitlab', + integration_type: 'oauth', + platform_installation_id: crypto.randomUUID(), + integration_status: 'active', + repository_access: 'all', + }); +} + +// --------------------------------------------------------------------------- +// Role matrix: every organization-security-agent-router procedure × five roles +// --------------------------------------------------------------------------- + +const memberProcedures: Array<{ name: string; input: Record }> = [ + { name: 'trackUiInteraction', input: { interaction: 'findings_filtered' } }, + { name: 'getPermissionStatus', input: {} }, + { name: 'getConfig', input: {} }, + { name: 'getRepositories', input: {} }, + { name: 'listFindings', input: {} }, + { name: 'getFinding', input: { id: crypto.randomUUID() } }, + { name: 'getStats', input: {} }, + { name: 'getDashboardStats', input: {} }, + { name: 'getLastSyncTime', input: {} }, + { name: 'triggerSync', input: {} }, + { name: 'startAnalysis', input: { findingId: crypto.randomUUID() } }, + { name: 'startRemediation', input: { findingId: crypto.randomUUID() } }, + { name: 'retryRemediation', input: { findingId: crypto.randomUUID() } }, + { name: 'cancelRemediation', input: { attemptId: crypto.randomUUID() } }, + { name: 'getAnalysis', input: { findingId: crypto.randomUUID() } }, + { name: 'getCommandStatus', input: { commandId: crypto.randomUUID() } }, + { name: 'getCommandStatuses', input: { commandIds: [crypto.randomUUID()] } }, + { name: 'listActiveCommands', input: {} }, + { name: 'getOrphanedRepositories', input: {} }, + { name: 'getAutoDismissEligible', input: {} }, +]; + +const billingProcedures: Array<{ name: string; input: Record }> = [ + { name: 'saveConfig', input: { expectedRevision: null } }, + { name: 'setEnabled', input: { isEnabled: false } }, + { name: 'dismissFinding', input: { findingId: crypto.randomUUID(), reason: 'not_used' } }, + { name: 'deleteFindingsByRepository', input: { repoFullName: 'acme/api' } }, + { name: 'autoDismissEligible', input: {} }, + { name: 'getAuditReport', input: {} }, +]; + +function expectsDeny(gate: Gate, roleKey: RoleKey): boolean { + if (roleKey === 'non_member') return true; + return gate === 'billing' && !(ORGANIZATION_BILLING_ROLES as readonly string[]).includes(roleKey); +} + +async function expectGateAllows(promise: Promise): Promise { + try { + await promise; + } catch (error) { + expect((error as { code?: string } | null)?.code).not.toBe('UNAUTHORIZED'); + } +} + +describe('organization security agent router role matrix', () => { + it.each([ + ...memberProcedures.map(p => ({ ...p, gate: 'member' as const })), + ...billingProcedures.map(p => ({ ...p, gate: 'billing' as const })), + ])('$name ($gate gate) allows and denies the five roles', async ({ name, gate, input }) => { + for (const roleKey of ROLE_KEYS) { + const securityAgent = callers[roleKey].organizations.securityAgent as unknown as Record< + string, + (input: unknown) => Promise + >; + const promise = securityAgent[name]({ organizationId: organization.id, ...input }); + + if (expectsDeny(gate, roleKey)) { + await expect(promise).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + } else { + await expectGateAllows(promise); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// connectWithPAT: rejects roles outside ORGANIZATION_BILLING_ROLES +// --------------------------------------------------------------------------- + +describe('gitlabRouter.connectWithPAT role gate', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedConnectWithPAT.mockResolvedValue({ + success: true, + integration: { + id: crypto.randomUUID(), + accountLogin: 'gitlab-user', + accountId: '42', + instanceUrl: 'https://gitlab.com', + }, + }); + }); + + it.each([ + ['owner', false], + ['admin', false], + ['billing_manager', false], + ['member', true], + ['non_member', true], + ] as const)('role %s is %s', async (roleKey, shouldDeny) => { + const promise = callers[roleKey].gitlab.connectWithPAT({ + token: 'glpat-test-token', + instanceUrl: 'https://gitlab.com', + organizationId: organization.id, + }); + + if (shouldDeny) { + await expect(promise).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(mockedConnectWithPAT).not.toHaveBeenCalled(); + } else { + await expect(promise).resolves.toMatchObject({ success: true }); + expect(mockedConnectWithPAT).toHaveBeenCalledTimes(1); + } + }); +}); + +// --------------------------------------------------------------------------- +// GitLab OAuth replacement gate (start + callback) +// --------------------------------------------------------------------------- + +describe('GitLab OAuth connect replacement gate', () => { + beforeEach(async () => { + jest.clearAllMocks(); + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_organization_id, organization.id)); + mockedGetUserFromAuth.mockResolvedValue({ user: users.member, authFailedResponse: null }); + mockedBuildGitLabOAuthUrl.mockReturnValue('https://gitlab.com/oauth/authorize?state=signed'); + }); + + it('denies a member when the org already has a GitLab integration', async () => { + await seedGitLabIntegration(); + + const response = await handleGitLabOAuthConnect( + makeRequest(`/api/integrations/gitlab/connect?organizationId=${organization.id}`) + ); + + expectRedirect( + response, + `/organizations/${organization.id}/integrations/gitlab?error=permission_required` + ); + expect(mockedBuildGitLabOAuthUrl).not.toHaveBeenCalled(); + }); + + it('allows a billing role to replace an existing GitLab integration', async () => { + await seedGitLabIntegration(); + mockedGetUserFromAuth.mockResolvedValue({ user: users.owner, authFailedResponse: null }); + + const response = await handleGitLabOAuthConnect( + makeRequest(`/api/integrations/gitlab/connect?organizationId=${organization.id}`) + ); + + expect(response.headers.get('location')).toBe( + 'https://gitlab.com/oauth/authorize?state=signed' + ); + expect(mockedBuildGitLabOAuthUrl).toHaveBeenCalledTimes(1); + }); + + it('allows a member for a first-time connect', async () => { + const response = await handleGitLabOAuthConnect( + makeRequest(`/api/integrations/gitlab/connect?organizationId=${organization.id}`) + ); + + expect(response.headers.get('location')).toBe( + 'https://gitlab.com/oauth/authorize?state=signed' + ); + expect(mockedBuildGitLabOAuthUrl).toHaveBeenCalledTimes(1); + }); + + it('denies a member on the POST path with a permission error and non-5xx status', async () => { + await seedGitLabIntegration(); + + const response = await handleGitLabOAuthConnectPost( + new NextRequest('http://localhost:3000/api/integrations/gitlab/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ organizationId: organization.id }), + }) + ); + + expect(response.status).toBe(403); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe('permission_required'); + expect(mockedBuildGitLabOAuthUrl).not.toHaveBeenCalled(); + }); + + it('redirects a non-member to organization_access_required', async () => { + mockedGetUserFromAuth.mockResolvedValue({ user: users.non_member, authFailedResponse: null }); + + const response = await handleGitLabOAuthConnect( + makeRequest(`/api/integrations/gitlab/connect?organizationId=${organization.id}`) + ); + + expectRedirect( + response, + `/organizations/${organization.id}/integrations/gitlab?error=organization_access_required` + ); + expect(mockedBuildGitLabOAuthUrl).not.toHaveBeenCalled(); + }); +}); + +describe('GitLab OAuth callback replacement gate', () => { + beforeEach(async () => { + jest.clearAllMocks(); + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_organization_id, organization.id)); + mockedGetUserFromAuth.mockResolvedValue({ user: users.member, authFailedResponse: null }); + }); + + function makeOrgState(userId: string = users.member.id): string { + return createGitLabOAuthState({ owner: { type: 'org', id: organization.id } }, userId); + } + + function mockSuccessfulGitLabOAuthExchange(): void { + mockedExchangeGitLabOAuthCode.mockResolvedValue({ + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + expires_in: 7200, + created_at: 1234567890, + scope: 'api read_user', + }); + mockedFetchGitLabUser.mockResolvedValue({ + id: 42, + username: 'gitlab-user', + name: 'GitLab User', + email: 'user@example.com', + avatar_url: 'https://example.com/avatar.png', + web_url: 'https://gitlab.com/gitlab-user', + }); + mockedFetchGitLabProjects.mockResolvedValue([]); + mockedCalculateTokenExpiry.mockReturnValue('2026-01-01T00:00:00.000Z'); + mockedStoreGitLabOAuthIntegration.mockResolvedValue({ + integrationId: crypto.randomUUID(), + instanceChanged: false, + }); + } + + it('denies a member when the org already has a GitLab integration', async () => { + await seedGitLabIntegration(); + + const state = makeOrgState(); + const response = await handleGitLabOAuthCallback( + makeRequest(`/api/integrations/gitlab/callback?code=abc&state=${encodeURIComponent(state)}`) + ); + + expectRedirect( + response, + `/organizations/${organization.id}/integrations/gitlab?error=permission_required` + ); + expect(mockedExchangeGitLabOAuthCode).not.toHaveBeenCalled(); + }); + + it('allows a member for a first-time connect', async () => { + mockSuccessfulGitLabOAuthExchange(); + + const state = makeOrgState(); + const response = await handleGitLabOAuthCallback( + makeRequest(`/api/integrations/gitlab/callback?code=abc&state=${encodeURIComponent(state)}`) + ); + + expectRedirect( + response, + `/organizations/${organization.id}/integrations/gitlab?success=connected` + ); + expect(mockedExchangeGitLabOAuthCode).toHaveBeenCalledTimes(1); + }); + + it('allows a billing role to replace an existing GitLab integration', async () => { + await seedGitLabIntegration(); + mockedGetUserFromAuth.mockResolvedValue({ user: users.owner, authFailedResponse: null }); + mockSuccessfulGitLabOAuthExchange(); + + const state = makeOrgState(users.owner.id); + const response = await handleGitLabOAuthCallback( + makeRequest(`/api/integrations/gitlab/callback?code=abc&state=${encodeURIComponent(state)}`) + ); + + expectRedirect( + response, + `/organizations/${organization.id}/integrations/gitlab?success=connected` + ); + expect(mockedExchangeGitLabOAuthCode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/routers/security-agent-router.ts b/apps/web/src/routers/security-agent-router.ts index ec7ce67e39..a3e1cbb913 100644 --- a/apps/web/src/routers/security-agent-router.ts +++ b/apps/web/src/routers/security-agent-router.ts @@ -70,6 +70,9 @@ export const securityAgentRouter = createTRPCRouter({ getCommandStatus: baseProcedure .input(handlers.getCommandStatus.inputSchema) .query(handlers.getCommandStatus.handler), + getCommandStatuses: baseProcedure + .input(handlers.getCommandStatuses.inputSchema) + .query(handlers.getCommandStatuses.handler), listActiveCommands: baseProcedure.query(handlers.listActiveCommands), getOrphanedRepositories: baseProcedure.query(handlers.getOrphanedRepositories), deleteFindingsByRepository: baseProcedure diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 7c0cb31772..b29646a291 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -1,15 +1,20 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; import { + agent_configs, credit_transactions, device_sessions, + kiloclaw_instances, kilocode_users, magic_link_tokens, + organization_memberships, + organizations, user_notification_preferences, user_push_tokens, } from '@kilocode/db/schema'; import { eq, inArray } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createTestOrganization } from '@/tests/helpers/organization.helper'; import type { User } from '@kilocode/db/schema'; import { sendSignInCodeEmail } from '@/lib/email'; import { @@ -47,6 +52,31 @@ const mockSendDeletionSupportNotification = jest.mocked(sendAccountDeletionSuppo const mockPerformGdprRemoval = jest.mocked(performGdprRemoval); const mockAssertUserCanBeSoftDeleted = jest.mocked(assertUserCanBeSoftDeleted); +const AVAILABLE_CAPABILITY = { available: true, unavailableReason: null }; +const UNAVAILABLE_BALANCE_ALERTS = { + available: false, + unavailableReason: 'Join an organization to get balance alerts.', +}; +const UNAVAILABLE_SECURITY_FINDINGS = { + available: false, + unavailableReason: 'Enable Kilo Security Agent on a scope to get security findings.', +}; +const UNAVAILABLE_KILOCLAW_ACTIVITY = { + available: false, + unavailableReason: 'Start a KiloClaw instance to get KiloClaw activity.', +}; + +/** Capabilities for a user with no org, no Security config, and no KiloClaw instance. */ +const NO_GATES_CAPABILITIES = { + chatMessages: AVAILABLE_CAPABILITY, + agentAttention: AVAILABLE_CAPABILITY, + agentUpdates: AVAILABLE_CAPABILITY, + sessionStatus: AVAILABLE_CAPABILITY, + kiloclawActivity: UNAVAILABLE_KILOCLAW_ACTIVITY, + balanceAlerts: UNAVAILABLE_BALANCE_ALERTS, + securityFindings: UNAVAILABLE_SECURITY_FINDINGS, +}; + let testUser: User; let surveyTestUser: User; let skipTestUser: User; @@ -613,6 +643,7 @@ describe('user router - notification preferences', () => { securityFindings: true, notificationPreviews: 'generic', agentPushEnabled: true, + capabilities: NO_GATES_CAPABILITIES, }); // Legacy compat: agentUpdates and agentPushEnabled always share the same value. expect(result.agentUpdates).toBe(result.agentPushEnabled); @@ -643,6 +674,7 @@ describe('user router - notification preferences', () => { securityFindings: true, notificationPreviews: 'generic', agentPushEnabled: false, + capabilities: NO_GATES_CAPABILITIES, }); expect(result.agentUpdates).toBe(result.agentPushEnabled); }); @@ -704,6 +736,7 @@ describe('user router - notification preferences', () => { securityFindings: true, notificationPreviews: 'generic', agentPushEnabled: true, + capabilities: NO_GATES_CAPABILITIES, }); const firstPrefs = await firstCaller.user.getNotificationPreferences(); @@ -717,6 +750,7 @@ describe('user router - notification preferences', () => { securityFindings: true, notificationPreviews: 'generic', agentPushEnabled: false, + capabilities: NO_GATES_CAPABILITIES, }); // Setting second user's preference must not affect first user's row. @@ -920,6 +954,133 @@ describe('user router - notification preferences', () => { }); }); +describe('user router - notification capabilities', () => { + let capUser: User; + + beforeAll(async () => { + capUser = await insertTestUser({ + google_user_email: 'notif-caps@example.com', + google_user_name: 'Notif Caps', + }); + }); + + afterEach(async () => { + // Remove every gate fixture so each test starts from the no-gates baseline. + await db.delete(kiloclaw_instances).where(eq(kiloclaw_instances.user_id, capUser.id)); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.kilo_user_id, capUser.id)); + await db.delete(organizations).where(eq(organizations.created_by_kilo_user_id, capUser.id)); + await db.delete(agent_configs).where(eq(agent_configs.owned_by_user_id, capUser.id)); + await db + .delete(user_notification_preferences) + .where(eq(user_notification_preferences.user_id, capUser.id)); + }); + + afterAll(async () => { + await db.delete(kilocode_users).where(eq(kilocode_users.id, capUser.id)); + }); + + it('reports balanceAlerts unavailable with a reason when the user has no organization', async () => { + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities.balanceAlerts).toEqual(UNAVAILABLE_BALANCE_ALERTS); + expect(result.capabilities.kiloclawActivity).toEqual(UNAVAILABLE_KILOCLAW_ACTIVITY); + expect(result.capabilities.securityFindings).toEqual(UNAVAILABLE_SECURITY_FINDINGS); + // The four always-on categories stay available for a signed-in user. + expect(result.capabilities.chatMessages).toEqual(AVAILABLE_CAPABILITY); + expect(result.capabilities.agentAttention).toEqual(AVAILABLE_CAPABILITY); + expect(result.capabilities.agentUpdates).toEqual(AVAILABLE_CAPABILITY); + expect(result.capabilities.sessionStatus).toEqual(AVAILABLE_CAPABILITY); + }); + + it('reports securityFindings unavailable when Security is disabled everywhere', async () => { + // The user has an org (so balanceAlerts is available) but no Security config. + await createTestOrganization('cap-org', capUser.id, 0); + + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities.balanceAlerts).toEqual(AVAILABLE_CAPABILITY); + expect(result.capabilities.securityFindings).toEqual(UNAVAILABLE_SECURITY_FINDINGS); + }); + + it('reports securityFindings available when the personal scope has Security enabled', async () => { + await db.insert(agent_configs).values({ + owned_by_user_id: capUser.id, + agent_type: 'security_scan', + platform: 'github', + config: {}, + is_enabled: true, + created_by: capUser.id, + }); + + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities.securityFindings).toEqual(AVAILABLE_CAPABILITY); + }); + + it('reports securityFindings unavailable when the personal scope has Security disabled', async () => { + await db.insert(agent_configs).values({ + owned_by_user_id: capUser.id, + agent_type: 'security_scan', + platform: 'github', + config: {}, + is_enabled: false, + created_by: capUser.id, + }); + + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities.securityFindings).toEqual(UNAVAILABLE_SECURITY_FINDINGS); + }); + + it('reports kiloclawActivity unavailable when the only instance is destroyed', async () => { + await db.insert(kiloclaw_instances).values({ + user_id: capUser.id, + sandbox_id: 'cap-sandbox-destroyed', + destroyed_at: '2026-01-01T00:00:00.000Z', + }); + + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities.kiloclawActivity).toEqual(UNAVAILABLE_KILOCLAW_ACTIVITY); + }); + + it('reports every capability available when all gates are satisfied', async () => { + const org = await createTestOrganization('cap-org', capUser.id, 0); + await db.insert(agent_configs).values({ + owned_by_organization_id: org.id, + agent_type: 'security_scan', + platform: 'github', + config: {}, + is_enabled: true, + created_by: capUser.id, + }); + await db.insert(kiloclaw_instances).values({ + user_id: capUser.id, + sandbox_id: 'cap-sandbox', + }); + + const caller = await createCallerForUser(capUser.id); + const result = await caller.user.getNotificationPreferences(); + + expect(result.capabilities).toEqual({ + chatMessages: AVAILABLE_CAPABILITY, + agentAttention: AVAILABLE_CAPABILITY, + agentUpdates: AVAILABLE_CAPABILITY, + sessionStatus: AVAILABLE_CAPABILITY, + kiloclawActivity: AVAILABLE_CAPABILITY, + balanceAlerts: AVAILABLE_CAPABILITY, + securityFindings: AVAILABLE_CAPABILITY, + }); + }); +}); + describe('user router - register push token', () => { let tokenUser: User; diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index fe7e212bf1..ed3d45f916 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -36,8 +36,9 @@ import { kiloclaw_subscriptions, user_notification_preferences, user_push_tokens, + agent_configs, } from '@kilocode/db/schema'; -import { eq, and, isNull, inArray, sql, gte, gt, desc, isNotNull } from 'drizzle-orm'; +import { eq, and, isNull, inArray, or, sql, gte, gt, desc, isNotNull } from 'drizzle-orm'; import crypto from 'crypto'; import { checkDiscordGuildMembership } from '@/lib/integrations/discord-guild-membership'; import { AuthProviderIdSchema } from '@/lib/auth/provider-metadata'; @@ -54,6 +55,7 @@ import { getCreditBlocks } from '@/lib/getCreditBlocks'; import { resolveStripeReceiptUrl } from '@/lib/credits'; import { getBalanceForUser } from '@/lib/user/balance'; import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage'; +import { getUserOrganizationsWithSeats } from '@/lib/organizations/organizations'; import { revokeWebSessions } from '@/lib/web-session-revocation'; const ACCOUNT_DELETION_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour @@ -372,6 +374,87 @@ async function enrichDeductionsWithInstanceNames( }); } +// The seven notification category keys are owned by the mobile app: +// `NOTIFICATION_CATEGORY_KEYS` / `NotificationCategoryKey` in +// `apps/mobile/src/lib/hooks/agent-push-preference.ts`. The server hard-codes +// the same string literals; do not define a duplicate server category-key type. +const NOTIFICATION_CATEGORY_KEYS = [ + 'chatMessages', + 'agentAttention', + 'agentUpdates', + 'sessionStatus', + 'kiloclawActivity', + 'balanceAlerts', + 'securityFindings', +] as const; + +type NotificationCapability = { available: boolean; unavailableReason: string | null }; +type NotificationCapabilities = Record< + (typeof NOTIFICATION_CATEGORY_KEYS)[number], + NotificationCapability +>; + +const ALWAYS_AVAILABLE_CAPABILITY: NotificationCapability = { + available: true, + unavailableReason: null, +}; + +function unavailableCapability(reason: string): NotificationCapability { + return { available: false, unavailableReason: reason }; +} + +/** + * Compute the per-category availability map for the signed-in user. The four + * always-on categories need no data; the three gated categories each run one + * read-only existence check. + */ +async function computeNotificationCapabilities(userId: string): Promise { + const organizations = await getUserOrganizationsWithSeats(userId); + const organizationIds = organizations.map(organization => organization.organizationId); + + const [securityConfigs, kiloclawInstances] = await Promise.all([ + db + .select({ id: agent_configs.id }) + .from(agent_configs) + .where( + and( + eq(agent_configs.agent_type, 'security_scan'), + eq(agent_configs.is_enabled, true), + or( + eq(agent_configs.owned_by_user_id, userId), + inArray(agent_configs.owned_by_organization_id, organizationIds) + ) + ) + ) + .limit(1), + db + .select({ id: kiloclaw_instances.id }) + .from(kiloclaw_instances) + .where(and(eq(kiloclaw_instances.user_id, userId), isNull(kiloclaw_instances.destroyed_at))) + .limit(1), + ]); + + const hasOrganization = organizations.length > 0; + const hasSecurityConfig = securityConfigs.length > 0; + const hasKiloclawInstance = kiloclawInstances.length > 0; + + return { + chatMessages: ALWAYS_AVAILABLE_CAPABILITY, + agentAttention: ALWAYS_AVAILABLE_CAPABILITY, + agentUpdates: ALWAYS_AVAILABLE_CAPABILITY, + sessionStatus: ALWAYS_AVAILABLE_CAPABILITY, + balanceAlerts: hasOrganization + ? ALWAYS_AVAILABLE_CAPABILITY + : unavailableCapability('Join an organization to get balance alerts.'), + securityFindings: hasSecurityConfig + ? ALWAYS_AVAILABLE_CAPABILITY + : unavailableCapability('Enable Kilo Security Agent on a scope to get security findings.'), + kiloclawActivity: hasKiloclawInstance + ? ALWAYS_AVAILABLE_CAPABILITY + : unavailableCapability('Start a KiloClaw instance to get KiloClaw activity.'), + }; +} + export const userRouter = createTRPCRouter({ // Account linking routes getMe: baseProcedure.query(async ({ ctx }) => { @@ -1115,6 +1198,7 @@ export const userRouter = createTRPCRouter({ // `agentUpdates` and legacy `agentPushEnabled` both map to the same physical // column `agent_push_enabled`; ship both keys for shipped-client compat. const agentPushEnabled = row?.agent_push_enabled ?? true; + const capabilities = await computeNotificationCapabilities(ctx.user.id); return { chatMessages: row?.chat_messages_enabled ?? true, agentAttention: row?.agent_attention_enabled ?? true, @@ -1125,6 +1209,7 @@ export const userRouter = createTRPCRouter({ securityFindings: row?.security_findings_enabled ?? true, notificationPreviews: row?.notification_previews ?? 'generic', agentPushEnabled, + capabilities, }; }), diff --git a/packages/app-shared/src/analytics/event-map.test.ts b/packages/app-shared/src/analytics/event-map.test.ts index 38bcad3add..e31c0736db 100644 --- a/packages/app-shared/src/analytics/event-map.test.ts +++ b/packages/app-shared/src/analytics/event-map.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; +import { SECURITY_COMMAND_TYPES } from '@kilocode/app-shared/security-agent'; + import { ACCESS_REQUIRED_SHOWN_EVENT, ANALYTICS_EVENT_SCHEMAS, APP_STARTUP_EVENT, CLAW_WEATHER_LOCATION_SELECTED_EVENT, CLAW_WEATHER_LOCATION_SKIPPED_EVENT, + CODE_REVIEW_SETTLED_EVENT, COMPLETION_REACHED_EVENT, CONVERSATION_CREATED_EVENT, FEEDBACK_SUBMITTED_EVENT, @@ -27,6 +30,8 @@ import { PURCHASE_SETTLED_EVENT, QUESTION_ANSWERED_EVENT, SECURITY_COMMAND_SETTLED_EVENT, + SECURITY_INTENT_FOR_COMMAND_TYPE, + SECURITY_INTENTS, SESSION_CREATED_EVENT, SESSION_CREATE_SETTLED_EVENT, SESSION_VIEWED_EVENT, @@ -62,6 +67,7 @@ const ALL_EVENT_CONSTANTS = [ SESSION_CREATE_SETTLED_EVENT, PR_OPERATION_SETTLED_EVENT, SECURITY_COMMAND_SETTLED_EVENT, + CODE_REVIEW_SETTLED_EVENT, PURCHASE_SETTLED_EVENT, ]; @@ -173,7 +179,7 @@ describe('phase classification', () => { KILO_PASS_PURCHASE_COMPLETED_EVENT, APP_STARTUP_EVENT, ]; - expect(terminal).toHaveLength(4); + expect(terminal).toHaveLength(5); for (const name of TERMINAL_PHASE_EVENTS) { expect(ANALYTICS_EVENT_SCHEMAS).toHaveProperty(name); } @@ -194,6 +200,41 @@ describe('phase classification', () => { }); }); +describe('security intent map', () => { + it('keys the map by the shared command-type authority', () => { + expect(new Set(Object.keys(SECURITY_INTENT_FOR_COMMAND_TYPE))).toEqual( + new Set(SECURITY_COMMAND_TYPES) + ); + }); + + it('maps every command type to exactly one intent and covers every intent', () => { + const commandTypes = Object.keys(SECURITY_INTENT_FOR_COMMAND_TYPE); + const intents = Object.values(SECURITY_INTENT_FOR_COMMAND_TYPE); + + // The map is a bijection: every command type has exactly one intent and no + // two command types share an intent. + expect(commandTypes).toHaveLength(4); + expect(new Set(intents).size).toBe(commandTypes.length); + + // The intents cover every SECURITY_INTENTS member. `sync` maps to + // `manual_sync`, so an array-equality assertion between the command types + // and the intents can never pass. + expect(new Set(intents)).toEqual(new Set(SECURITY_INTENTS)); + }); + + it('pins the exact command-to-intent pairing', () => { + // A value swap (e.g. `sync: 'dismiss_finding'`) would pass the key-set and + // value-set assertions above, so pin the whole map. `sync` must map to the + // legacy ledger intent `manual_sync` that deployed producers emit. + expect(SECURITY_INTENT_FOR_COMMAND_TYPE).toEqual({ + sync: 'manual_sync', + dismiss_finding: 'dismiss_finding', + start_analysis: 'start_analysis', + apply_auto_remediation: 'apply_auto_remediation', + }); + }); +}); + describe('organization_member_invited role schema', () => { const invitedSchema = ANALYTICS_EVENT_SCHEMAS[ORGANIZATION_MEMBER_INVITED_EVENT]; diff --git a/packages/app-shared/src/analytics/event-map.ts b/packages/app-shared/src/analytics/event-map.ts index 147aa94c7e..1556ae822e 100644 --- a/packages/app-shared/src/analytics/event-map.ts +++ b/packages/app-shared/src/analytics/event-map.ts @@ -13,6 +13,8 @@ */ import { z } from 'zod'; +import type { SecurityCommandType } from '@kilocode/app-shared/security-agent'; + import { ORGANIZATION_ROLES } from '../organizations/roles'; // ----- shared enums ------------------------------------------------------- @@ -76,7 +78,23 @@ export const PR_INTENTS = [ 'create_review_comment', 'reply_comment', ] as const; -export const SECURITY_INTENTS = ['manual_sync', 'dismiss_finding'] as const; +export const SECURITY_INTENTS = [ + 'manual_sync', + 'dismiss_finding', + 'start_analysis', + 'apply_auto_remediation', +] as const; + +/** + * Ledger intent per security command type. The ledger intent names predate the + * command tuple: the `sync` command type uses the `manual_sync` intent. + */ +export const SECURITY_INTENT_FOR_COMMAND_TYPE = { + sync: 'manual_sync', + dismiss_finding: 'dismiss_finding', + start_analysis: 'start_analysis', + apply_auto_remediation: 'apply_auto_remediation', +} as const satisfies Record; export const PR_RECONCILE_RESULTS = [ 'confirmed_completed', 'confirmed_absent', @@ -118,6 +136,7 @@ export const LOGIN_EVENT = 'login'; export const SESSION_CREATE_SETTLED_EVENT = 'session_create_settled'; export const PR_OPERATION_SETTLED_EVENT = 'pr_operation_settled'; export const SECURITY_COMMAND_SETTLED_EVENT = 'security_command_settled'; +export const CODE_REVIEW_SETTLED_EVENT = 'code_review_settled'; export const PURCHASE_SETTLED_EVENT = 'purchase_settled'; /** @@ -143,6 +162,7 @@ export const TERMINAL_PHASE_EVENTS = [ SESSION_CREATE_SETTLED_EVENT, PR_OPERATION_SETTLED_EVENT, SECURITY_COMMAND_SETTLED_EVENT, + CODE_REVIEW_SETTLED_EVENT, PURCHASE_SETTLED_EVENT, ] as const; @@ -279,6 +299,14 @@ export const ANALYTICS_EVENT_SCHEMAS = { duration_ms: metric, }) .strict(), + [CODE_REVIEW_SETTLED_EVENT]: z + .object({ + ...terminalBase, + surface: z.literal('code_review'), + intent: z.enum(['manual', 'webhook']), + duration_ms: metric, + }) + .strict(), [PURCHASE_SETTLED_EVENT]: z .object({ ...terminalBase, diff --git a/packages/app-shared/src/security-agent/commands.test.ts b/packages/app-shared/src/security-agent/commands.test.ts index 9088e4e814..526d8f47a7 100644 --- a/packages/app-shared/src/security-agent/commands.test.ts +++ b/packages/app-shared/src/security-agent/commands.test.ts @@ -4,6 +4,7 @@ import { getSecurityCommandInvalidationScopes, isActiveSecurityCommand, mergeTrackedCommandIds, + SECURITY_COMMAND_TYPES, type SecurityCommand, } from './commands'; @@ -38,6 +39,12 @@ describe('security agent command helpers', () => { ]); }); + it('maps every command type to a non-empty invalidation scope list', () => { + for (const commandType of SECURITY_COMMAND_TYPES) { + expect(getSecurityCommandInvalidationScopes(commandType).length).toBeGreaterThan(0); + } + }); + it('deduplicates recovered and locally tracked command ids', () => { expect(mergeTrackedCommandIds(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c']); }); diff --git a/packages/app-shared/src/security-agent/commands.ts b/packages/app-shared/src/security-agent/commands.ts index 7221685e73..4cfb9cf442 100644 --- a/packages/app-shared/src/security-agent/commands.ts +++ b/packages/app-shared/src/security-agent/commands.ts @@ -1,8 +1,10 @@ -export type SecurityCommandType = - | 'sync' - | 'dismiss_finding' - | 'start_analysis' - | 'apply_auto_remediation'; +export const SECURITY_COMMAND_TYPES = [ + 'sync', + 'dismiss_finding', + 'start_analysis', + 'apply_auto_remediation', +] as const; +export type SecurityCommandType = (typeof SECURITY_COMMAND_TYPES)[number]; // Web's full invalidation-scope superset (from // apps/web/src/components/security-agent/security-agent-command-invalidation.ts:6). diff --git a/packages/app-shared/src/security-agent/presentation.ts b/packages/app-shared/src/security-agent/presentation.ts index 90972aca5b..ca5fe861b0 100644 --- a/packages/app-shared/src/security-agent/presentation.ts +++ b/packages/app-shared/src/security-agent/presentation.ts @@ -650,6 +650,8 @@ export function formatValidationEvidenceEntry( // copy in the mobile tree (use-security-findings.ts imports it from here). const REMEDIATION_UNAVAILABLE_COPY = { finding_not_found: 'Security finding no longer exists.', + approval_required: + 'Auto Remediation requires approval. Start remediation manually to approve it.', finding_not_open: 'Finding is no longer open.', repo_not_in_scope: 'Repository is not selected for Security Agent.', analysis_required: 'Run codebase analysis before starting remediation.', diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index e1ec824799..0f6099ef36 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -43,6 +43,7 @@ export { createSecurityAgentCommand, deleteRetainedSecurityAgentCommands, getSecurityAgentCommandForOwner, + getSecurityAgentCommandsForOwner, isTerminalSecurityAgentCommandTransitionOutcome, listActiveSecurityAgentCommandsForOwner, markSecurityAgentCommandQueueAdmissionFailed, diff --git a/packages/db/src/operation-ledger.ts b/packages/db/src/operation-ledger.ts index 3584f4cd57..2d328bb63a 100644 --- a/packages/db/src/operation-ledger.ts +++ b/packages/db/src/operation-ledger.ts @@ -55,7 +55,7 @@ export const OPERATION_TAXONOMIES = ['safe-retry', 'reconcile-first', 'never-rep export type OperationTaxonomy = (typeof OPERATION_TAXONOMIES)[number]; /** Ledger domains. `create_remote` session identity lives in the DO, not here. */ -export const OPERATION_DOMAINS = ['session', 'pr', 'security', 'purchase'] as const; +export const OPERATION_DOMAINS = ['session', 'pr', 'security', 'code_review', 'purchase'] as const; export type OperationDomain = (typeof OPERATION_DOMAINS)[number]; export const OPERATION_TERMINAL_STATUSES = [ diff --git a/packages/db/src/security-agent-command-repository.ts b/packages/db/src/security-agent-command-repository.ts index 6a59da7351..b20d1bcb18 100644 --- a/packages/db/src/security-agent-command-repository.ts +++ b/packages/db/src/security-agent-command-repository.ts @@ -229,6 +229,18 @@ export async function getSecurityAgentCommandForOwner( return command ?? null; } +export async function getSecurityAgentCommandsForOwner( + db: SecurityAgentCommandDb, + owner: SecurityAgentCommandOwner, + ids: string[] +): Promise { + if (ids.length === 0) return []; + return db + .select() + .from(security_agent_commands) + .where(and(ownerWhere(owner), inArray(security_agent_commands.id, ids))); +} + export async function listActiveSecurityAgentCommandsForOwner( db: SecurityAgentCommandDb, owner: SecurityAgentCommandOwner, diff --git a/packages/notifications/src/push-data.test.ts b/packages/notifications/src/push-data.test.ts new file mode 100644 index 0000000000..604987e10c --- /dev/null +++ b/packages/notifications/src/push-data.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { pushDataSchema } from './push-data'; + +const lifecycleEvents = [ + 'analysis_completed', + 'analysis_failed', + 'remediation_queued', + 'remediation_pr_opened', + 'remediation_failed', + 'remediation_blocked', + 'remediation_no_changes_needed', + 'remediation_cancelled', +] as const; + +describe('pushDataSchema security_lifecycle', () => { + it('parses a round-trip for every event value', () => { + for (const event of lifecycleEvents) { + const payload = { + type: 'security_lifecycle', + event, + findingId: 'finding-1', + scope: 'org', + }; + const parsed = pushDataSchema.parse(payload); + expect(parsed).toEqual(payload); + } + }); + + it('parses the optional remediationId and prUrl fields', () => { + const payload = { + type: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-1', + scope: 'org', + remediationId: 'remediation-1', + prUrl: 'https://github.com/org/repo/pull/1', + }; + expect(pushDataSchema.parse(payload)).toEqual(payload); + }); + + it('rejects an unknown event value', () => { + const payload = { + type: 'security_lifecycle', + event: 'sla_warning', + findingId: 'finding-1', + scope: 'org', + }; + expect(pushDataSchema.safeParse(payload).success).toBe(false); + }); + + it('rejects an empty findingId or scope', () => { + expect( + pushDataSchema.safeParse({ + type: 'security_lifecycle', + event: 'analysis_completed', + findingId: '', + scope: 'org', + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + type: 'security_lifecycle', + event: 'analysis_completed', + findingId: 'finding-1', + scope: '', + }).success + ).toBe(false); + }); +}); + +describe('pushDataSchema unknown type', () => { + it('fails to parse an unknown type, proving old-client drop behavior', () => { + const payload = { + type: 'security_lifecycle_v2', + event: 'analysis_completed', + findingId: 'finding-1', + scope: 'org', + }; + expect(pushDataSchema.safeParse(payload).success).toBe(false); + }); +}); diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts index e0bffc6864..29e0531f29 100644 --- a/packages/notifications/src/push-data.ts +++ b/packages/notifications/src/push-data.ts @@ -47,6 +47,35 @@ export const pushDataSchema = z.discriminatedUnion('type', [ findingId: nonEmptyStringSchema, scope: nonEmptyStringSchema, }), + // 1:1 map to SecurityAuditLogAction (packages/db/src/schema-types.ts): + // analysis_completed -> FindingAnalysisCompleted, + // analysis_failed -> FindingAnalysisFailed, + // remediation_queued -> RemediationQueued, + // remediation_pr_opened -> RemediationPrOpened, + // remediation_failed -> RemediationFailed, + // remediation_blocked -> RemediationBlocked, + // remediation_no_changes_needed -> RemediationNoChangesNeeded, + // remediation_cancelled -> RemediationCancelled. + // FindingCreated is intentionally unmapped: finding creation already sends + // the visible `security_finding` push, so a second visible push would + // double-notify. + z.object({ + type: z.literal('security_lifecycle'), + event: z.enum([ + 'analysis_completed', + 'analysis_failed', + 'remediation_queued', + 'remediation_pr_opened', + 'remediation_failed', + 'remediation_blocked', + 'remediation_no_changes_needed', + 'remediation_cancelled', + ]), + findingId: nonEmptyStringSchema, + scope: nonEmptyStringSchema, + remediationId: nonEmptyStringSchema.optional(), + prUrl: nonEmptyStringSchema.optional(), + }), ]); export type PushData = z.infer; diff --git a/packages/notifications/src/push-presentation.test.ts b/packages/notifications/src/push-presentation.test.ts index caa6ab0424..e65687a181 100644 --- a/packages/notifications/src/push-presentation.test.ts +++ b/packages/notifications/src/push-presentation.test.ts @@ -18,6 +18,7 @@ const variants = [ { type: 'cloud_agent_session', cliSessionId: 'cli1', category: 'attention' }, { type: 'low_balance', organizationId: 'org1' }, { type: 'security_finding', findingId: 'f1', scope: 'org' }, + { type: 'security_lifecycle', event: 'analysis_completed', findingId: 'f1', scope: 'org' }, ] as const; describe('androidChannelIdForPushData', () => { @@ -42,6 +43,7 @@ describe('androidChannelIdForPushData', () => { 'scheduled-action': 'kiloclaw', low_balance: 'balance', security_finding: 'security', + security_lifecycle: 'security', }; for (const variant of variants) { @@ -77,4 +79,19 @@ describe('genericPushContentForPushData', () => { expect(body.length).toBeGreaterThan(0); } }); + + it('returns the security lifecycle copy for the security_lifecycle variant', () => { + const parsed = pushDataSchema.parse({ + type: 'security_lifecycle', + event: 'remediation_failed', + findingId: 'f1', + scope: 'org', + remediationId: 'r1', + prUrl: 'https://github.com/org/repo/pull/1', + }); + expect(genericPushContentForPushData(parsed)).toEqual({ + title: 'Kilo', + body: 'A security finding needs attention', + }); + }); }); diff --git a/packages/notifications/src/push-presentation.ts b/packages/notifications/src/push-presentation.ts index 379ef99139..3032f2bfd6 100644 --- a/packages/notifications/src/push-presentation.ts +++ b/packages/notifications/src/push-presentation.ts @@ -28,6 +28,7 @@ export function androidChannelIdForPushData(data: PushData): AndroidNotification case 'low_balance': return 'balance'; case 'security_finding': + case 'security_lifecycle': return 'security'; default: { // Exhaustiveness: new PushData variants must be handled above. @@ -55,6 +56,7 @@ export function genericPushContentForPushData(data: PushData): { title: string; case 'low_balance': return { title: 'Kilo', body: 'Your balance needs attention' }; case 'security_finding': + case 'security_lifecycle': return { title: 'Kilo', body: 'A security finding needs attention' }; default: { // Exhaustiveness: new PushData variants must be handled above. diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index 1f17b1fc20..834681e608 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -289,8 +289,38 @@ export type InternalDispatchSecurityFindingRequest = z.infer< typeof internalDispatchSecurityFindingRequestSchema >; +// 1:1 map to the `security_lifecycle` event enum in `push-data.ts` (which in +// turn maps to SecurityAuditLogAction). Kept as a standalone enum here because +// the push-data variant declares the enum inline and this package must not +// import it back. +export const securityLifecycleEventSchema = z.enum([ + 'analysis_completed', + 'analysis_failed', + 'remediation_queued', + 'remediation_pr_opened', + 'remediation_failed', + 'remediation_blocked', + 'remediation_no_changes_needed', + 'remediation_cancelled', +]); +export type SecurityLifecycleEvent = z.infer; + +export const internalDispatchSecurityLifecycleRequestSchema = z.object({ + kind: z.literal('security_lifecycle'), + event: securityLifecycleEventSchema, + findingId: z.string().min(1), + scope: z.string().min(1), + remediationId: z.string().min(1).optional(), + prUrl: z.string().min(1).optional(), + recipientUserIds: z.array(z.string().min(1)).min(1), +}); +export type InternalDispatchSecurityLifecycleRequest = z.infer< + typeof internalDispatchSecurityLifecycleRequestSchema +>; + export const internalDispatchRequestSchema = z.discriminatedUnion('kind', [ internalDispatchLowBalanceRequestSchema, internalDispatchSecurityFindingRequestSchema, + internalDispatchSecurityLifecycleRequestSchema, ]); export type InternalDispatchRequest = z.infer; diff --git a/packages/notifications/src/rpc-schemas.type-test.ts b/packages/notifications/src/rpc-schemas.type-test.ts index 0513e670ce..c4697a820c 100644 --- a/packages/notifications/src/rpc-schemas.type-test.ts +++ b/packages/notifications/src/rpc-schemas.type-test.ts @@ -1,4 +1,5 @@ import type { + InternalDispatchSecurityLifecycleRequest, ScheduledActionEvent, SendScheduledActionNoticeParams, SendScheduledActionNoticeResult, @@ -23,5 +24,16 @@ const scheduledActionResult = { receiptCount: 1, } satisfies SendScheduledActionNoticeResult; +const securityLifecycleRequest = { + kind: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-1', + scope: 'org-1', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + recipientUserIds: ['user-a', 'user-b'], +} satisfies InternalDispatchSecurityLifecycleRequest; + void scheduledActionParams; void scheduledActionResult; +void securityLifecycleRequest; diff --git a/packages/trpc/src/mobile.ts b/packages/trpc/src/mobile.ts index 59526f4381..03d66bf950 100644 --- a/packages/trpc/src/mobile.ts +++ b/packages/trpc/src/mobile.ts @@ -6,6 +6,7 @@ import { cliSessionsV2Router } from '@/routers/cli-sessions-v2-router'; import { cloudAgentNextRouter } from '@/routers/cloud-agent-next-router'; import { githubAppsRouter } from '@/routers/github-apps-router'; import { codeReviewRouter } from '@/routers/code-reviews/code-reviews-router'; +import { reviewMemoryRouter } from '@/routers/code-reviews/review-memory-router'; import { personalReviewAgentRouter } from '@/routers/code-reviews-router'; import { securityAgentRouter } from '@/routers/security-agent-router'; import { kiloPassRouter } from '@/routers/kilo-pass-router'; @@ -31,6 +32,7 @@ const mobileRouter = createTRPCRouter({ cloudAgentNext: cloudAgentNextRouter, githubApps: githubAppsRouter, codeReviews: codeReviewRouter, + reviewMemory: reviewMemoryRouter, personalReviewAgent: personalReviewAgentRouter, securityAgent: securityAgentRouter, kiloPass: kiloPassRouter, diff --git a/packages/worker-utils/src/security-remediation-policy.test.ts b/packages/worker-utils/src/security-remediation-policy.test.ts index 9b687dd602..c8a8fb00cf 100644 --- a/packages/worker-utils/src/security-remediation-policy.test.ts +++ b/packages/worker-utils/src/security-remediation-policy.test.ts @@ -12,6 +12,7 @@ const baseConfig: SecurityRemediationConfig = { auto_remediation_enabled: true, auto_remediation_min_severity: 'high', auto_remediation_include_existing: true, + auto_remediation_require_approval: false, auto_remediation_enabled_at: '2026-01-01T00:00:00.000Z', }; @@ -461,4 +462,56 @@ describe('decideSecurityRemediationEligibility', () => { }); expect(beforeEnablement).toMatchObject({ eligible: false, reason: 'before_enablement' }); }); + + it('admits auto_policy remediation when approval is not required', () => { + const decision = decideSecurityRemediationEligibility({ + finding: baseFinding, + config: { ...baseConfig, auto_remediation_require_approval: false }, + isAgentEnabled: true, + repoFullNamesInScope: ['kilo/repo'], + origin: 'auto_policy', + blockState: emptyBlockState, + }); + + expect(decision).toMatchObject({ eligible: true, reason: 'eligible' }); + }); + + it('rejects auto_policy remediation with approval_required when approval is required', () => { + const decision = decideSecurityRemediationEligibility({ + finding: baseFinding, + config: { ...baseConfig, auto_remediation_require_approval: true }, + isAgentEnabled: true, + repoFullNamesInScope: ['kilo/repo'], + origin: 'auto_policy', + blockState: emptyBlockState, + }); + + expect(decision).toMatchObject({ eligible: false, reason: 'approval_required' }); + }); + + it('rejects bulk_existing remediation with approval_required when approval is required', () => { + const decision = decideSecurityRemediationEligibility({ + finding: baseFinding, + config: { ...baseConfig, auto_remediation_require_approval: true }, + isAgentEnabled: true, + repoFullNamesInScope: ['kilo/repo'], + origin: 'bulk_existing', + blockState: emptyBlockState, + }); + + expect(decision).toMatchObject({ eligible: false, reason: 'approval_required' }); + }); + + it('never rejects manual remediation for the approval flag', () => { + const decision = decideSecurityRemediationEligibility({ + finding: baseFinding, + config: { ...baseConfig, auto_remediation_require_approval: true }, + isAgentEnabled: true, + repoFullNamesInScope: ['kilo/repo'], + origin: 'manual', + blockState: emptyBlockState, + }); + + expect(decision).toMatchObject({ eligible: true, reason: 'eligible' }); + }); }); diff --git a/packages/worker-utils/src/security-remediation-policy.ts b/packages/worker-utils/src/security-remediation-policy.ts index 8bd8476cd9..462641d4be 100644 --- a/packages/worker-utils/src/security-remediation-policy.ts +++ b/packages/worker-utils/src/security-remediation-policy.ts @@ -9,6 +9,7 @@ export type SecurityRemediationConfig = { auto_remediation_enabled: boolean; auto_remediation_min_severity: SecurityRemediationMinSeverity; auto_remediation_include_existing: boolean; + auto_remediation_require_approval: boolean; auto_remediation_enabled_at: string | null; }; @@ -94,16 +95,23 @@ export const SECURITY_REMEDIATION_REJECTION_REASONS = [ export type SecurityRemediationRejectionReason = (typeof SECURITY_REMEDIATION_REJECTION_REASONS)[number]; -export type SecurityRemediationCapabilityReason = 'eligible' | SecurityRemediationRejectionReason; export const SECURITY_REMEDIATION_ADMISSION_REJECTION_REASONS = [ ...SECURITY_REMEDIATION_REJECTION_REASONS, 'finding_not_found', + 'approval_required', ] as const; export type SecurityRemediationAdmissionRejectionReason = (typeof SECURITY_REMEDIATION_ADMISSION_REJECTION_REASONS)[number]; +// The capability decision reuses the admission reason set: the approval flag +// rejects auto admission with `approval_required` (manual start is the +// approval path and never reaches it). +export type SecurityRemediationCapabilityReason = + | 'eligible' + | SecurityRemediationAdmissionRejectionReason; + export type SecurityRemediationEligibilityParams = { finding: SecurityRemediationFinding; config: SecurityRemediationConfig; @@ -375,6 +383,7 @@ export function decideSecurityRemediationEligibility( if (!hasConcretePath) return reject('action_not_concrete'); if (!params.config.auto_remediation_enabled) return reject('auto_remediation_disabled'); + if (params.config.auto_remediation_require_approval) return reject('approval_required'); if (params.origin === 'bulk_existing' && !params.config.auto_remediation_include_existing) { return reject('include_existing_disabled'); } diff --git a/services/notifications/src/lib/internal-dispatch-push.test.ts b/services/notifications/src/lib/internal-dispatch-push.test.ts index 645d8458b2..c645e17be7 100644 --- a/services/notifications/src/lib/internal-dispatch-push.test.ts +++ b/services/notifications/src/lib/internal-dispatch-push.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { - DispatchPushInput, - DispatchPushOutcome, - InternalDispatchLowBalanceRequest, - InternalDispatchSecurityFindingRequest, +import { + pushDataSchema, + type DispatchPushInput, + type DispatchPushOutcome, + type InternalDispatchLowBalanceRequest, + type InternalDispatchSecurityFindingRequest, + type InternalDispatchSecurityLifecycleRequest, } from '@kilocode/notifications'; import type { UserNotificationPreferences } from './cloud-agent-session-push'; @@ -50,6 +52,21 @@ function securityFinding( }; } +function securityLifecycle( + overrides: Partial = {} +): InternalDispatchSecurityLifecycleRequest { + return { + kind: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-1', + scope: 'org-1', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + recipientUserIds: ['user-a', 'user-b'], + ...overrides, + }; +} + function expectedLowBalanceInput(userId: string): DispatchPushInput { return { userId, @@ -368,4 +385,121 @@ describe('dispatchInternalPushCore', () => { expect(calls.dispatchPushInputs).toHaveLength(2); expect(calls.dispatchPushInputs.map(i => i.userId)).toEqual(['user-a', 'user-b']); }); + + it('security_lifecycle dispatches all recipients with a payload that validates', async () => { + const { deps, calls } = fakeDeps(); + const result = await dispatchInternalPushCore(securityLifecycle(), deps); + + expect(result.perRecipient).toEqual([ + { userId: 'user-a', outcome: 'delivered' }, + { userId: 'user-b', outcome: 'delivered' }, + ]); + expect(calls.dispatchPushInputs).toHaveLength(2); + for (const input of calls.dispatchPushInputs) { + expect(input.push.data).toEqual({ + type: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-1', + scope: 'org-1', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + }); + expect(pushDataSchema.safeParse(input.push.data).success).toBe(true); + } + expect(calls.dispatchPushInputs[0]).toEqual({ + userId: 'user-a', + presenceContext: null, + idempotencyKey: 'security-lifecycle:finding-1:remediation_pr_opened:remediation-1', + badge: null, + push: { + title: 'Kilo', + body: 'A security finding needs attention', + data: { + type: 'security_lifecycle', + event: 'remediation_pr_opened', + findingId: 'finding-1', + scope: 'org-1', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + }, + sound: 'default', + priority: 'high', + }, + } satisfies DispatchPushInput); + }); + + it('security_lifecycle omits optional fields when absent and still validates', async () => { + const { deps, calls } = fakeDeps(); + const result = await dispatchInternalPushCore( + securityLifecycle({ + event: 'analysis_completed', + remediationId: undefined, + prUrl: undefined, + recipientUserIds: ['user-a'], + }), + deps + ); + + expect(result.perRecipient).toEqual([{ userId: 'user-a', outcome: 'delivered' }]); + expect(calls.dispatchPushInputs).toHaveLength(1); + expect(calls.dispatchPushInputs[0].push.data).toEqual({ + type: 'security_lifecycle', + event: 'analysis_completed', + findingId: 'finding-1', + scope: 'org-1', + }); + expect(pushDataSchema.safeParse(calls.dispatchPushInputs[0].push.data).success).toBe(true); + expect(calls.dispatchPushInputs[0].idempotencyKey).toBe( + 'security-lifecycle:finding-1:analysis_completed:none' + ); + }); + + it('suppresses security_lifecycle when securityFindingsEnabled is false (no DO call)', async () => { + const { deps, calls } = fakeDeps({ + preferences: { ...ALL_ON, securityFindingsEnabled: false }, + }); + const result = await dispatchInternalPushCore(securityLifecycle(), deps); + + expect(result.perRecipient).toEqual([ + { userId: 'user-a', outcome: 'suppressed_preference' }, + { userId: 'user-b', outcome: 'suppressed_preference' }, + ]); + expect(calls.dispatchPushInputs).toHaveLength(0); + }); + + it('security_lifecycle reads only securityFindingsEnabled (ignores all other categories)', async () => { + const { deps, calls } = fakeDeps({ + preferences: { + ...ALL_ON, + agentPushEnabled: false, + chatMessagesEnabled: false, + agentAttentionEnabled: false, + sessionStatusEnabled: false, + kiloclawActivityEnabled: false, + balanceAlertsEnabled: false, + securityFindingsEnabled: true, + }, + }); + const result = await dispatchInternalPushCore( + securityLifecycle({ recipientUserIds: ['user-a'] }), + deps + ); + + expect(result.perRecipient).toEqual([{ userId: 'user-a', outcome: 'delivered' }]); + expect(calls.dispatchPushInputs).toHaveLength(1); + }); + + it('dedups duplicate recipient ids in security_lifecycle to one call', async () => { + const { deps, calls } = fakeDeps(); + const result = await dispatchInternalPushCore( + securityLifecycle({ recipientUserIds: ['user-a', 'user-a', 'user-b'] }), + deps + ); + + expect(result.perRecipient).toEqual([ + { userId: 'user-a', outcome: 'delivered' }, + { userId: 'user-b', outcome: 'delivered' }, + ]); + expect(calls.dispatchPushInputs).toHaveLength(2); + }); }); diff --git a/services/notifications/src/lib/internal-dispatch-push.ts b/services/notifications/src/lib/internal-dispatch-push.ts index b948718e4c..76c4dfde94 100644 --- a/services/notifications/src/lib/internal-dispatch-push.ts +++ b/services/notifications/src/lib/internal-dispatch-push.ts @@ -1,6 +1,7 @@ /** - * Pure core for internal dispatch of low-balance and security-finding pushes. - * IO is injected via deps so unit tests can substitute in-memory fakes. + * Pure core for internal dispatch of low-balance, security-finding, and + * security-lifecycle pushes. IO is injected via deps so unit tests can + * substitute in-memory fakes. */ import type { @@ -51,56 +52,87 @@ function securityFindingTitle( } function buildDispatchInput(userId: string, input: InternalDispatchRequest): DispatchPushInput { - if (input.kind === 'low_balance') { - return { - userId, - presenceContext: null, - idempotencyKey: `low-balance:${input.organizationId}`, - badge: null, - push: { - title: 'Low balance alert', - body: `${input.organizationName} balance fell below $${input.minimumBalanceUsd}`, - data: { - type: 'low_balance', - organizationId: input.organizationId, + switch (input.kind) { + case 'low_balance': + return { + userId, + presenceContext: null, + idempotencyKey: `low-balance:${input.organizationId}`, + badge: null, + push: { + title: 'Low balance alert', + body: `${input.organizationName} balance fell below $${input.minimumBalanceUsd}`, + data: { + type: 'low_balance', + organizationId: input.organizationId, + }, + sound: 'default', + priority: 'high', }, - sound: 'default', - priority: 'high', - }, - } satisfies DispatchPushInput; + } satisfies DispatchPushInput; + case 'security_finding': { + const title = securityFindingTitle(input.notificationKind, input.severity); + return { + userId, + presenceContext: null, + // Sibling finding rows for one advisory (per manifest, per scope) must + // collapse to one push per recipient; the DO instance is already per + // recipient. `notificationKind` stays in the key so `new_finding` and SLA + // pushes stay distinct. + idempotencyKey: input.ghsaId + ? `security-finding:${input.repoFullName}:${input.ghsaId}:${input.notificationKind}` + : `security-finding:${input.notificationId}`, + badge: null, + push: { + title, + body: `${input.title} in ${input.repoFullName}`, + data: { + type: 'security_finding', + findingId: input.findingId, + scope: input.scope, + }, + sound: 'default', + priority: 'high', + }, + } satisfies DispatchPushInput; + } + case 'security_lifecycle': + // Lifecycle events carry no finding title/severity, so the full-preview + // copy matches the generic preview copy in `push-presentation.ts`. + return { + userId, + presenceContext: null, + idempotencyKey: `security-lifecycle:${input.findingId}:${input.event}:${input.remediationId ?? 'none'}`, + badge: null, + push: { + title: 'Kilo', + body: 'A security finding needs attention', + data: { + type: 'security_lifecycle', + event: input.event, + findingId: input.findingId, + scope: input.scope, + ...(input.remediationId !== undefined ? { remediationId: input.remediationId } : {}), + ...(input.prUrl !== undefined ? { prUrl: input.prUrl } : {}), + }, + sound: 'default', + priority: 'high', + }, + } satisfies DispatchPushInput; } - - const title = securityFindingTitle(input.notificationKind, input.severity); - return { - userId, - presenceContext: null, - // Sibling finding rows for one advisory (per manifest, per scope) must - // collapse to one push per recipient; the DO instance is already per - // recipient. `notificationKind` stays in the key so `new_finding` and SLA - // pushes stay distinct. - idempotencyKey: input.ghsaId - ? `security-finding:${input.repoFullName}:${input.ghsaId}:${input.notificationKind}` - : `security-finding:${input.notificationId}`, - badge: null, - push: { - title, - body: `${input.title} in ${input.repoFullName}`, - data: { - type: 'security_finding', - findingId: input.findingId, - scope: input.scope, - }, - sound: 'default', - priority: 'high', - }, - } satisfies DispatchPushInput; } function categoryEnabled( prefs: UserNotificationPreferences, kind: InternalDispatchRequest['kind'] ): boolean { - return kind === 'low_balance' ? prefs.balanceAlertsEnabled : prefs.securityFindingsEnabled; + switch (kind) { + case 'low_balance': + return prefs.balanceAlertsEnabled; + case 'security_finding': + case 'security_lifecycle': + return prefs.securityFindingsEnabled; + } } /** Narrow DO outcomes that cannot occur with null presence / no rate limit. */ @@ -123,7 +155,7 @@ export async function dispatchInternalPushCore( const recipients: string[] = []; const seen = new Set(); - if (input.kind === 'low_balance') { + if (input.kind === 'low_balance' || input.kind === 'security_lifecycle') { for (const id of input.recipientUserIds) { if (seen.has(id)) continue; seen.add(id); diff --git a/services/security-auto-analysis/src/callbacks.lifecycle.test.ts b/services/security-auto-analysis/src/callbacks.lifecycle.test.ts new file mode 100644 index 0000000000..58b1128505 --- /dev/null +++ b/services/security-auto-analysis/src/callbacks.lifecycle.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorkerDb } from '@kilocode/db/client'; +import { transitionAnalysisCallbackLifecycle } from './analysis-start-lifecycle.js'; +import { + getActiveAnalysisAttemptToken, + getAnalysisActorById, + getSecurityFindingById, +} from './db/queries.js'; +import { generateApiToken } from './token.js'; +import { extractSandboxAnalysis } from './extraction.js'; +import { maybeAutoDismissCompletedAnalysis } from './auto-dismiss.js'; +import { trackSecurityAnalysisCompleted } from './posthog.js'; +import { + dispatchSecurityLifecycleEventForFinding, + maybeAdmitAutoRemediationForCompletedAnalysis, +} from './remediation.js'; +import { + finalizeCompletedAnalysisCallbackFromEnv, + finalizeFailedAnalysisCallbackFromEnv, +} from './callbacks.js'; + +vi.mock('./analysis-start-lifecycle.js', () => ({ + transitionAnalysisCallbackLifecycle: vi.fn(), +})); + +vi.mock('./db/queries.js', () => ({ + getSecurityFindingById: vi.fn(), + getActiveAnalysisAttemptToken: vi.fn(), + getAnalysisActorById: vi.fn(), +})); + +vi.mock('./token.js', () => ({ + generateApiToken: vi.fn(), +})); + +vi.mock('./extraction.js', () => ({ + extractSandboxAnalysis: vi.fn(), +})); + +vi.mock('./auto-dismiss.js', () => ({ + maybeAutoDismissCompletedAnalysis: vi.fn(), +})); + +vi.mock('./posthog.js', () => ({ + trackSecurityAnalysisCompleted: vi.fn(), +})); + +vi.mock('./remediation.js', () => ({ + maybeAdmitAutoRemediationForCompletedAnalysis: vi.fn(), + dispatchSecurityLifecycleEventForFinding: vi.fn(), +})); + +vi.mock('@kilocode/db/client', () => ({ + getWorkerDb: vi.fn(), +})); + +const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const ATTEMPT_TOKEN = 'attempt-token-123'; +const db = {} as never; + +const env = { + HYPERDRIVE: { connectionString: 'postgres://test' }, + NEXTAUTH_SECRET: { get: vi.fn().mockResolvedValue('nextauth-secret') }, + ENVIRONMENT: 'development', + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + SESSION_INGEST_WORKER_URL: 'https://session-ingest.test', +} as unknown as CloudflareEnv; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getWorkerDb).mockReturnValue(db); + vi.mocked(transitionAnalysisCallbackLifecycle).mockResolvedValue({ + status: 'completed', + } as never); + vi.mocked(dispatchSecurityLifecycleEventForFinding).mockResolvedValue(undefined); +}); + +describe('analysis lifecycle push emit wiring', () => { + it('emits analysis_completed when a completed callback finalizes', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + analysis: { triggeredByUserId: 'user-1' }, + } as never); + vi.mocked(getActiveAnalysisAttemptToken).mockResolvedValue(ATTEMPT_TOKEN); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + is_admin: false, + } as never); + vi.mocked(generateApiToken).mockResolvedValue('api-token'); + vi.mocked(extractSandboxAnalysis).mockResolvedValue({ + isExploitable: false, + extractionStatus: 'succeeded', + exploitabilityReasoning: 'No reachable usage', + usageLocations: [], + suggestedFix: 'Upgrade package', + suggestedAction: 'dismiss', + summary: 'Not exploitable.', + rawMarkdown: '# Completed analysis', + analysisAt: '2026-01-01T00:00:00.000Z', + } as never); + vi.mocked(maybeAutoDismissCompletedAnalysis).mockResolvedValue(undefined); + vi.mocked(maybeAdmitAutoRemediationForCompletedAnalysis).mockResolvedValue({ + admitted: false, + reason: 'monitor_required', + } as never); + vi.mocked(trackSecurityAnalysisCompleted).mockResolvedValue(undefined); + + await expect( + finalizeCompletedAnalysisCallbackFromEnv({ + env, + findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + lastAssistantMessageText: '# Completed analysis', + }, + }) + ).resolves.toEqual({ status: 'completed-finalized' }); + + expect(dispatchSecurityLifecycleEventForFinding).toHaveBeenCalledWith({ + env, + db, + findingId: FINDING_ID, + event: 'analysis_completed', + }); + }); + + it('emits analysis_failed when a completed callback result text is missing', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + analysis: null, + } as never); + vi.mocked(getActiveAnalysisAttemptToken).mockResolvedValue(ATTEMPT_TOKEN); + + await expect( + finalizeCompletedAnalysisCallbackFromEnv({ + env, + findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + }, + }) + ).resolves.toEqual({ status: 'result-missing' }); + + expect(dispatchSecurityLifecycleEventForFinding).toHaveBeenCalledWith({ + env, + db, + findingId: FINDING_ID, + event: 'analysis_failed', + }); + }); + + it('emits analysis_failed when a failed callback finalizes', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'running', + } as never); + vi.mocked(getActiveAnalysisAttemptToken).mockResolvedValue(ATTEMPT_TOKEN); + + await expect( + finalizeFailedAnalysisCallbackFromEnv({ + env, + findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'failed', + errorMessage: 'upstream 503', + }, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + + expect(dispatchSecurityLifecycleEventForFinding).toHaveBeenCalledWith({ + env, + db, + findingId: FINDING_ID, + event: 'analysis_failed', + }); + }); + + it('does not emit for non-terminal callback dispositions', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'failed', + } as never); + vi.mocked(getActiveAnalysisAttemptToken).mockResolvedValue(null); + + await expect( + finalizeFailedAnalysisCallbackFromEnv({ + env, + findingId: FINDING_ID, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'failed', + errorMessage: 'upstream 503', + }, + }) + ).resolves.toEqual({ status: 'already-terminal' }); + + expect(dispatchSecurityLifecycleEventForFinding).not.toHaveBeenCalled(); + }); + + it('still emits analysis_completed when a post-commit follow-up throws', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + analysis: { triggeredByUserId: 'user-1' }, + } as never); + vi.mocked(getActiveAnalysisAttemptToken).mockResolvedValue(ATTEMPT_TOKEN); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + is_admin: false, + } as never); + vi.mocked(generateApiToken).mockResolvedValue('api-token'); + vi.mocked(extractSandboxAnalysis).mockResolvedValue({ + isExploitable: false, + extractionStatus: 'succeeded', + exploitabilityReasoning: 'No reachable usage', + usageLocations: [], + suggestedFix: 'Upgrade package', + suggestedAction: 'dismiss', + summary: 'Not exploitable.', + rawMarkdown: '# Completed analysis', + analysisAt: '2026-01-01T00:00:00.000Z', + } as never); + vi.mocked(maybeAutoDismissCompletedAnalysis).mockRejectedValue( + new Error('auto-dismiss unavailable') + ); + vi.mocked(maybeAdmitAutoRemediationForCompletedAnalysis).mockRejectedValue( + new Error('auto-remediate unavailable') + ); + vi.mocked(trackSecurityAnalysisCompleted).mockRejectedValue(new Error('posthog unavailable')); + + await expect( + finalizeCompletedAnalysisCallbackFromEnv({ + env, + findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + lastAssistantMessageText: '# Completed analysis', + }, + }) + ).resolves.toEqual({ status: 'completed-finalized' }); + + expect(dispatchSecurityLifecycleEventForFinding).toHaveBeenCalledWith({ + env, + db, + findingId: FINDING_ID, + event: 'analysis_completed', + }); + }); +}); diff --git a/services/security-auto-analysis/src/callbacks.ts b/services/security-auto-analysis/src/callbacks.ts index 7f120ec62b..49a21a4388 100644 --- a/services/security-auto-analysis/src/callbacks.ts +++ b/services/security-auto-analysis/src/callbacks.ts @@ -15,7 +15,10 @@ import { extractSandboxAnalysis as runSandboxExtraction } from './extraction.js' import { fetchLatestAssistantText as fetchSessionAssistantText } from './session-result.js'; import { maybeAutoDismissCompletedAnalysis } from './auto-dismiss.js'; import { trackSecurityAnalysisCompleted } from './posthog.js'; -import { maybeAdmitAutoRemediationForCompletedAnalysis } from './remediation.js'; +import { + maybeAdmitAutoRemediationForCompletedAnalysis, + dispatchSecurityLifecycleEventForFinding, +} from './remediation.js'; import type { AutoAnalysisFailureCode, SecurityFindingAnalysis, @@ -297,20 +300,42 @@ export async function finalizeCompletedAnalysisCallback(params: { }); if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; if (lifecycleTransition.status === 'stale-attempt') return { status: 'stale-attempt' }; - await params.maybeAutoDismissAnalysis?.({ - findingId: params.findingId, - analysis: completedAnalysis, - finding, + + // The terminal persist already committed, so the post-commit follow-ups are + // best-effort. A throw here must not fail the callback (a retry would hit the + // already-terminal guard and never re-run them) and must not skip the + // lifecycle push the FromEnv wrapper emits after this returns. + async function runBestEffortFollowUp(label: string, fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + console.error(`Security analysis callback ${label} follow-up failed`, { + findingId: params.findingId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + await runBestEffortFollowUp('auto-dismiss', async () => { + await params.maybeAutoDismissAnalysis?.({ + findingId: params.findingId, + analysis: completedAnalysis, + finding, + }); }); - await params.maybeAutoRemediateAnalysis?.({ - findingId: params.findingId, - analysis: completedAnalysis, - finding, + await runBestEffortFollowUp('auto-remediate', async () => { + await params.maybeAutoRemediateAnalysis?.({ + findingId: params.findingId, + analysis: completedAnalysis, + finding, + }); }); - await params.trackCompletedAnalysis?.({ - findingId: params.findingId, - analysis: completedAnalysis, - finding, + await runBestEffortFollowUp('track-completed', async () => { + await params.trackCompletedAnalysis?.({ + findingId: params.findingId, + analysis: completedAnalysis, + finding, + }); }); return { status: 'completed-finalized' }; } @@ -400,12 +425,21 @@ export async function finalizeFailedAnalysisCallbackFromEnv(params: { payload: SecurityAnalysisCallbackPayload; }): Promise<{ status: 'missing' | CallbackDisposition | 'failed-finalized' }> { const db = getWorkerDb(params.env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); - return finalizeFailedAnalysisCallback({ + const result = await finalizeFailedAnalysisCallback({ db, findingId: params.findingId, attemptToken: params.attemptToken, payload: params.payload, }); + if (result.status === 'failed-finalized') { + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db, + findingId: params.findingId, + event: 'analysis_failed', + }); + } + return result; } export async function finalizeCompletedAnalysisCallbackFromEnv(params: { @@ -417,7 +451,7 @@ export async function finalizeCompletedAnalysisCallbackFromEnv(params: { status: 'missing' | CallbackDisposition | 'completed-finalized' | 'result-missing'; }> { const db = getWorkerDb(params.env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); - return finalizeCompletedAnalysisCallback({ + const result = await finalizeCompletedAnalysisCallback({ db, findingId: params.findingId, attemptToken: params.attemptToken, @@ -482,6 +516,24 @@ export async function finalizeCompletedAnalysisCallbackFromEnv(params: { }); }, }); + if (result.status === 'completed-finalized') { + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db, + findingId: params.findingId, + event: 'analysis_completed', + }); + } else if (result.status === 'result-missing') { + // The lifecycle transition wrote a failed audit event for the missing + // result text, so the push mirrors that terminal outcome. + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db, + findingId: params.findingId, + event: 'analysis_failed', + }); + } + return result; } export async function finalizeAnalysisCallbackFromEnv(params: { diff --git a/services/security-auto-analysis/src/remediation-settled-outcomes.test.ts b/services/security-auto-analysis/src/remediation-settled-outcomes.test.ts new file mode 100644 index 0000000000..fd06831646 --- /dev/null +++ b/services/security-auto-analysis/src/remediation-settled-outcomes.test.ts @@ -0,0 +1,1310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorkerDb } from '@kilocode/db/client'; +import { + admitOperation, + recordOperationAcceptance, + settleOperation, +} from '@kilocode/db/operation-ledger'; +import { + agent_configs, + kilocode_users, + operation_ledgers, + platform_integrations, + security_findings, + security_remediation_attempts, +} from '@kilocode/db/schema'; +import type * as QueriesModule from './db/queries.js'; +import { getAnalysisActorById, getSecurityFindingById } from './db/queries.js'; +import { + admitSecurityRemediationLedgerRow, + cancelRemediation, + finalizeRemediationCallbackFromEnv, + maybeAdmitAutoRemediationForCompletedAnalysis, + processRemediationAttempt, + settleSecurityRemediationLedgerRow, + type SecurityRemediationCallbackPayload, +} from './remediation.js'; +import { DEFAULT_SECURITY_AGENT_CONFIG } from './types.js'; + +vi.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: vi.fn(), + recordOperationAcceptance: vi.fn(), + settleOperation: vi.fn(), +})); + +vi.mock('@kilocode/db/client', () => ({ + getWorkerDb: vi.fn(), +})); + +vi.mock('./db/queries.js', async importOriginal => ({ + ...(await importOriginal()), + getSecurityFindingById: vi.fn(), + getAnalysisActorById: vi.fn(), +})); + +vi.mock('@kilocode/worker-utils/security-finding-audit', () => ({ + SECURITY_FINDING_AUDIT_SYSTEM_ACTOR: { type: 'system' }, + buildSecurityFindingAuditHumanActor: vi.fn(), + deriveSecurityFindingAuditEventKey: vi.fn(() => 'security-remediation-event-key'), + insertSecurityFindingAuditEvent: vi.fn(), +})); + +const ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const REMEDIATION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const FINDING_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +const personalFinding = { + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, +} as never; + +/** A fake db whose `select` resolves rows by the queried table. */ +function fakeDbForLedger(ledgerRows: unknown[], userRows: unknown[] = []) { + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => { + const rows = + table === operation_ledgers ? ledgerRows : table === kilocode_users ? userRows : []; + return { + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve(rows)), + })), + }; + }), + })); + return { select } as never; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('security remediation ledger admission', () => { + it('admits the auto_policy operation with the right key and provider ref', async () => { + vi.mocked(admitOperation).mockResolvedValue({ + admission: 'admitted', + row: { id: 'ledger-row-1' }, + } as never); + vi.mocked(recordOperationAcceptance).mockResolvedValue({} as never); + + await admitSecurityRemediationLedgerRow({ + db: fakeDbForLedger([]), + finding: personalFinding, + attemptId: ATTEMPT_ID, + remediationId: REMEDIATION_ID, + attemptNumber: 1, + }); + + expect(admitOperation).toHaveBeenCalledWith(expect.anything(), { + userId: 'user-1', + orgId: null, + domain: 'security', + intent: 'apply_auto_remediation', + operationKey: `remediation:${ATTEMPT_ID}`, + resourceKey: `security:apply_auto_remediation:user:user-1:${FINDING_ID}`, + taxonomy: 'reconcile-first', + leaseSeconds: 120, + }); + expect(recordOperationAcceptance).toHaveBeenCalledWith(expect.anything(), { + rowId: 'ledger-row-1', + providerRef: ATTEMPT_ID, + canonicalResult: { + attemptId: ATTEMPT_ID, + remediationId: REMEDIATION_ID, + attemptNumber: 1, + }, + }); + }); +}); + +describe('security remediation ledger settlement', () => { + it('settles the terminal callback row once by row id', async () => { + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + await settleSecurityRemediationLedgerRow({ + db: fakeDbForLedger([{ id: 'ledger-row-1' }], [{ email: 'owner@example.com' }]), + finding: personalFinding, + attempt: { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }, + terminalStatus: 'pr_opened', + }); + + expect(settleOperation).toHaveBeenCalledTimes(1); + expect(settleOperation).toHaveBeenCalledWith(expect.anything(), { + rowId: 'ledger-row-1', + status: 'completed', + outboxEvent: { + eventName: 'security_command_settled', + distinctId: 'owner@example.com', + properties: { + source: 'server', + surface: 'security', + phase: 'terminal', + intent: 'apply_auto_remediation', + outcome: 'completed', + duration_ms: expect.any(Number), + }, + }, + }); + }); + + it('targets the same row id on a second settle and tolerates the no-op', async () => { + vi.mocked(settleOperation) + .mockResolvedValueOnce({ settled: true, row: { id: 'ledger-row-1' } } as never) + .mockResolvedValueOnce({ settled: false, row: { id: 'ledger-row-1' } } as never); + + const db = fakeDbForLedger([{ id: 'ledger-row-1' }]); + const attempt = { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }; + + await settleSecurityRemediationLedgerRow({ + db, + finding: personalFinding, + attempt, + terminalStatus: 'pr_opened', + }); + await settleSecurityRemediationLedgerRow({ + db, + finding: personalFinding, + attempt, + terminalStatus: 'pr_opened', + }); + + expect(settleOperation).toHaveBeenCalledTimes(2); + expect(settleOperation).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ rowId: 'ledger-row-1' }) + ); + expect(settleOperation).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ rowId: 'ledger-row-1' }) + ); + }); + + it('skips without throwing when the admit row is missing', async () => { + await expect( + settleSecurityRemediationLedgerRow({ + db: fakeDbForLedger([]), + finding: personalFinding, + attempt: { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }, + terminalStatus: 'failed', + }) + ).resolves.toBeUndefined(); + + expect(settleOperation).not.toHaveBeenCalled(); + }); + + it('maps terminal attempt statuses to analytics outcomes', async () => { + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = fakeDbForLedger([{ id: 'ledger-row-1' }]); + const attempt = { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }; + const cases = [ + ['pr_opened', 'completed'], + ['failed', 'failed'], + ['no_changes_needed', 'no_op'], + ['cancelled', 'interrupted'], + ['blocked', 'superseded'], + ] as const; + + for (const [terminalStatus, outcome] of cases) { + await settleSecurityRemediationLedgerRow({ + db, + finding: personalFinding, + attempt, + terminalStatus, + }); + expect(settleOperation).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + status: outcome, + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ outcome }), + }), + }) + ); + } + }); + + it('emits only the contract keys in the outbox payload', async () => { + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + await settleSecurityRemediationLedgerRow({ + db: fakeDbForLedger([{ id: 'ledger-row-1' }], [{ email: 'owner@example.com' }]), + finding: personalFinding, + attempt: { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }, + terminalStatus: 'pr_opened', + }); + + const outboxEvent = vi.mocked(settleOperation).mock.calls[0][1].outboxEvent; + expect(outboxEvent).toBeDefined(); + expect(Object.keys(outboxEvent!.properties).sort()).toEqual( + ['duration_ms', 'intent', 'outcome', 'phase', 'source', 'surface'].sort() + ); + // No free text: every value is an enum member or a metric number. + expect(outboxEvent!.properties).toEqual({ + source: 'server', + surface: 'security', + phase: 'terminal', + intent: 'apply_auto_remediation', + outcome: 'completed', + duration_ms: expect.any(Number), + }); + }); +}); + +// ----- callback settle wiring ------------------------------------------------- + +const ATTEMPT_TOKEN = 'attempt-token'; +const CLOUD_SESSION_ID = 'cloud-session-1'; + +async function sha256Hex(token: string): Promise { + const encoded = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest('SHA-256', encoded); + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); +} + +/** Extracts `{ column, value }` pairs from a drizzle `and(eq(...), ...)` SQL. */ +function extractEqPredicates(node: unknown): Array<{ column: string; value: string }> { + const out: Array<{ column: string; value: string }> = []; + const walk = (n: unknown) => { + if (!n || typeof n !== 'object') return; + const chunks = (n as { queryChunks?: unknown[] }).queryChunks; + if (Array.isArray(chunks)) { + const column = chunks.find( + c => c && typeof c === 'object' && typeof (c as { name?: unknown }).name === 'string' + ) as { name: string } | undefined; + const valueChunk = chunks.find( + c => + c && + typeof c === 'object' && + typeof (c as { value?: unknown }).value === 'string' && + !('name' in (c as object)) + ) as { value: string } | undefined; + if (column && valueChunk) out.push({ column: column.name, value: valueChunk.value }); + for (const c of chunks) walk(c); + } + }; + walk(node); + return out; +} + +function callbackDb(params: { attempts: unknown[]; ledgerRows?: unknown[]; userRows?: unknown[] }) { + const rowsFor = (table: unknown) => + table === security_remediation_attempts + ? params.attempts + : table === operation_ledgers + ? (params.ledgerRows ?? []) + : table === kilocode_users + ? (params.userRows ?? []) + : []; + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve(rowsFor(table))), + })), + })), + })); + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + }; + const transaction = vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)); + return { select, transaction } as never; +} + +async function callbackAttempt(overrides: Record = {}) { + return { + id: ATTEMPT_ID, + status: 'running', + callback_attempt_token_hash: await sha256Hex(ATTEMPT_TOKEN), + cloud_agent_session_id: CLOUD_SESSION_ID, + finding_id: FINDING_ID, + owned_by_organization_id: null, + owned_by_user_id: 'user-1', + queued_at: '2026-01-01T00:00:00.000Z', + requested_by_user_id: 'user-1', + cancellation_requested_at: null, + remediation_id: REMEDIATION_ID, + branch_name: 'security-remediation/test-1', + repo_full_name: 'kilo/repo', + remediation_model_slug: 'model', + origin: 'auto_policy', + ...overrides, + }; +} + +function callbackEnv(getTokenForRepo: ReturnType = vi.fn()): CloudflareEnv { + return { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: { getTokenForRepo }, + } as unknown as CloudflareEnv; +} + +function callbackPayload( + overrides: Partial = {} +): SecurityRemediationCallbackPayload { + return { + sessionId: 'session-1', + cloudAgentSessionId: CLOUD_SESSION_ID, + executionId: 'execution-1', + status: 'completed', + ...overrides, + }; +} + +function settleCallMatcher(outcome: string) { + return expect.objectContaining({ + rowId: 'ledger-row-1', + status: outcome, + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ outcome }), + }), + }); +} + +describe('security remediation callback settle wiring', () => { + it('settles the admitted row as completed on a successful pr_opened callback', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding as never); + vi.mocked(getAnalysisActorById).mockResolvedValue({ id: 'user-1' } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const getTokenForRepo = vi.fn().mockResolvedValue({ + success: true, + token: 'gh-token', + } as never); + const env = callbackEnv(getTokenForRepo); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + number: 123, + html_url: 'https://github.com/kilo/repo/pull/123', + draft: false, + base: { ref: 'main' }, + head: { ref: 'security-remediation/test-1', repo: { full_name: 'kilo/repo' } }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + ); + + vi.mocked(getWorkerDb).mockReturnValue( + callbackDb({ + attempts: [await callbackAttempt()], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }) + ); + + const result = await finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: callbackPayload({ + status: 'completed', + lastAssistantMessageText: [ + 'SECURITY_REMEDIATION_RESULT', + JSON.stringify({ + status: 'pr_opened', + prUrl: 'https://github.com/kilo/repo/pull/123', + prNumber: 123, + draft: false, + headBranch: 'security-remediation/test-1', + baseBranch: 'main', + summary: 'Opened PR', + validation: [], + riskNotes: null, + draftReason: null, + errorReason: null, + }), + 'END_SECURITY_REMEDIATION_RESULT', + ].join('\n'), + }), + }); + + expect(result).toEqual({ status: 'pr_opened-finalized' }); + expect(settleOperation).toHaveBeenCalledWith(expect.anything(), settleCallMatcher('completed')); + }); + + it('settles the admitted row as failed on a failed callback', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + vi.mocked(getWorkerDb).mockReturnValue( + callbackDb({ + attempts: [await callbackAttempt()], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }) + ); + + const result = await finalizeRemediationCallbackFromEnv({ + env: callbackEnv(), + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: callbackPayload({ status: 'failed', errorMessage: 'Cloud Agent failed' }), + }); + + expect(result).toEqual({ status: 'failed-finalized' }); + expect(settleOperation).toHaveBeenCalledWith(expect.anything(), settleCallMatcher('failed')); + }); + + it('settles the admitted row as interrupted on an interrupted callback with a cancellation request', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + vi.mocked(getWorkerDb).mockReturnValue( + callbackDb({ + attempts: [ + await callbackAttempt({ cancellation_requested_at: '2026-01-01T00:01:00.000Z' }), + ], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }) + ); + + const result = await finalizeRemediationCallbackFromEnv({ + env: callbackEnv(), + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: callbackPayload({ status: 'interrupted' }), + }); + + expect(result).toEqual({ status: 'cancelled-finalized' }); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('interrupted') + ); + }); + + it('does not settle when the attempt is already terminal', async () => { + vi.mocked(getWorkerDb).mockReturnValue( + callbackDb({ attempts: [await callbackAttempt({ status: 'pr_opened' })] }) + ); + + const result = await finalizeRemediationCallbackFromEnv({ + env: callbackEnv(), + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: callbackPayload({ status: 'completed' }), + }); + + expect(result).toEqual({ status: 'already-terminal' }); + expect(settleOperation).not.toHaveBeenCalled(); + }); +}); + +describe('security remediation ledger settle lookup', () => { + it('filters the settle lookup by domain, kilo_user_id, intent, and provider_ref', async () => { + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + let capturedWhere: unknown; + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn((whereArg: unknown) => { + if (table === operation_ledgers) capturedWhere = whereArg; + return { + limit: vi.fn(() => + Promise.resolve(table === operation_ledgers ? [{ id: 'ledger-row-1' }] : []) + ), + }; + }), + })), + })); + + await settleSecurityRemediationLedgerRow({ + db: { select } as never, + finding: personalFinding, + attempt: { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + }, + terminalStatus: 'failed', + }); + + expect(extractEqPredicates(capturedWhere)).toEqual([ + { column: 'domain', value: 'security' }, + { column: 'kilo_user_id', value: 'user-1' }, + { column: 'intent', value: 'apply_auto_remediation' }, + { column: 'provider_ref', value: ATTEMPT_ID }, + ]); + }); +}); + +// ----- repaired terminal-path settle wiring ---------------------------------- + +const QUEUED_AT = '2026-01-01T00:00:00.000Z'; + +/** A thenable drizzle result that also supports `.limit()` and `.orderBy()`. */ +function selectResult(rows: unknown[]) { + return { + then: (resolve: (value: unknown) => void) => resolve(rows), + limit: () => Promise.resolve(rows), + orderBy: () => selectResult(rows), + }; +} + +function eligibleAutoPolicyFinding() { + return { + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + status: 'open', + severity: 'high', + package_name: 'lodash', + package_ecosystem: 'npm', + dependency_scope: 'runtime', + cve_id: null, + ghsa_id: null, + cwe_ids: null, + cvss_score: null, + title: 'Test finding', + description: null, + vulnerable_version_range: '< 4.17.21', + patched_version: '4.17.21', + manifest_path: 'package.json', + raw_data: { updated_at: '2026-01-01T00:00:00.000Z' }, + last_synced_at: '2026-01-02T00:00:00.000Z', + analysis_status: 'completed', + analysis_completed_at: '2026-01-02T00:05:00.000Z', + analysis: { + analyzedAt: '2026-01-02T00:05:00.000Z', + sandboxAnalysis: { + isExploitable: true, + suggestedAction: 'open_pr', + suggestedFix: 'Upgrade lodash to 4.17.21', + usageLocations: [], + summary: 'Reachable vulnerable lodash usage', + rawMarkdown: '', + analysisAt: '2026-01-02T00:05:00.000Z', + }, + }, + } as never; +} + +function autoPolicyRuntimeConfig() { + return { + ...DEFAULT_SECURITY_AGENT_CONFIG, + auto_remediation_enabled: true, + auto_remediation_require_approval: false, + auto_remediation_enabled_at: '2026-01-01T00:00:00.000Z', + repository_selection_mode: 'all', + }; +} + +describe('maybeAdmitAutoRemediationForCompletedAnalysis enqueue-failure settle', () => { + it('settles the admitted row as failed after the queue admission failure', async () => { + const finding = eligibleAutoPolicyFinding(); + vi.mocked(getSecurityFindingById).mockResolvedValue(finding); + vi.mocked(admitOperation).mockResolvedValue({ + admission: 'admitted', + row: { id: 'ledger-row-1' }, + } as never); + vi.mocked(recordOperationAcceptance).mockResolvedValue({} as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const attemptRow = { + id: ATTEMPT_ID, + finding_id: FINDING_ID, + remediation_id: REMEDIATION_ID, + queued_at: QUEUED_AT, + attempt_number: 1, + }; + const remediationRow = { id: REMEDIATION_ID }; + + const outerRowsFor = (table: unknown) => { + if (table === agent_configs) return [{ config: autoPolicyRuntimeConfig(), is_enabled: true }]; + if (table === platform_integrations) + return [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }]; + if (table === security_remediation_attempts) + return [{ id: ATTEMPT_ID, queued_at: QUEUED_AT }]; + if (table === operation_ledgers) return [{ id: 'ledger-row-1' }]; + if (table === kilocode_users) return [{ email: 'owner@example.com' }]; + if (table === security_findings) return [finding]; + return []; + }; + + const db = { + select: vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => selectResult(outerRowsFor(table))), + })), + })), + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => { + const tx = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => ({ + returning: vi.fn(() => Promise.resolve([remediationRow])), + })), + returning: vi.fn(() => Promise.resolve([attemptRow])), + })), + })), + select: vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => selectResult(table === security_findings ? [finding] : [])), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + then: (resolve: (value: unknown) => void) => resolve([]), + returning: vi.fn(() => Promise.resolve([attemptRow])), + })), + })), + })), + }; + return cb(tx); + }), + }; + + const sendBatch = vi.fn().mockRejectedValue(new Error('queue down')); + + await expect( + maybeAdmitAutoRemediationForCompletedAnalysis({ + db: db as never, + env: { REMEDIATION_ATTEMPT_QUEUE: { sendBatch } } as unknown as CloudflareEnv, + findingId: FINDING_ID, + }) + ).rejects.toThrow('queue down'); + + expect(settleOperation).toHaveBeenCalledWith(expect.anything(), settleCallMatcher('failed')); + }); +}); + +describe('cancelRemediation queued-attempt settle', () => { + it('settles the admitted row as interrupted when a queued attempt is cancelled', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding as never); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + is_admin: false, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + vi.mocked(getWorkerDb).mockReturnValue( + callbackDb({ + attempts: [await callbackAttempt({ status: 'queued' })], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }) + ); + + const result = await cancelRemediation({ + env: { HYPERDRIVE: { connectionString: 'postgres://worker' } } as unknown as CloudflareEnv, + request: { + schemaVersion: 1, + attemptId: ATTEMPT_ID, + owner: { userId: 'user-1' }, + actorUserId: 'user-1', + }, + }); + + expect(result).toEqual({ success: true, status: 'cancelled' }); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('interrupted') + ); + }); +}); + +describe('processRemediationAttempt blocked-path settle', () => { + it('settles the admitted row as superseded when the attempt is blocked before launch', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + status: 'open', + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const attempt = { + id: ATTEMPT_ID, + finding_id: FINDING_ID, + owned_by_organization_id: null, + owned_by_user_id: 'user-1', + remediation_id: REMEDIATION_ID, + origin: 'auto_policy', + analysis_fingerprint: 'fingerprint', + requested_by_user_id: null, + repo_full_name: 'kilo/repo', + remediation_model_slug: 'model', + branch_name: 'security-remediation/test-1', + status: 'launching', + queued_at: QUEUED_AT, + claim_token: 'claim-token', + claimed_at: QUEUED_AT, + claimed_by_job_id: 'job-1', + launch_attempt_count: 1, + next_retry_at: null, + attempt_number: 1, + priority: 50, + }; + + const rowsFor = (table: unknown) => { + if (table === agent_configs) return [{ config: {}, is_enabled: false }]; + if (table === operation_ledgers) return [{ id: 'ledger-row-1' }]; + if (table === kilocode_users) return [{ email: 'owner@example.com' }]; + return []; + }; + + const db = { + execute: vi.fn(async () => ({ rows: [attempt] })), + select: vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => selectResult(rowsFor(table))), + })), + })), + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => { + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + then: (resolve: (value: unknown) => void) => resolve([]), + })), + })), + })), + }; + return cb(tx); + }), + }; + + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { HYPERDRIVE: { connectionString: 'postgres://worker' } } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('skipped'); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('superseded') + ); + }); +}); + +// ----- finding-missing and launch-path settle wiring ------------------------ + +/** A db that records the operation_ledgers settle lookup where clause. */ +function ledgerCapturingDb(params: { + attempt: unknown; + attempts?: unknown[]; + ledgerRows?: unknown[]; + userRows?: unknown[]; + agentConfigs?: unknown[]; + integrations?: unknown[]; +}) { + let capturedLedgerWhere: unknown; + const rowsFor = (table: unknown): unknown[] => { + if (table === security_remediation_attempts) return params.attempts ?? []; + if (table === operation_ledgers) return params.ledgerRows ?? []; + if (table === kilocode_users) return params.userRows ?? []; + if (table === agent_configs) return params.agentConfigs ?? []; + if (table === platform_integrations) return params.integrations ?? []; + return []; + }; + const chain = (table: unknown): unknown => ({ + then: (resolve: (value: unknown) => void) => resolve(rowsFor(table)), + limit: () => Promise.resolve(rowsFor(table)), + orderBy: () => chain(table), + innerJoin: () => chain(null), + where: (whereArg?: unknown) => { + if (table === operation_ledgers) capturedLedgerWhere = whereArg; + return chain(table); + }, + }); + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => chain(table)), + })); + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + }; + const transaction = vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)); + const execute = vi.fn(async () => ({ rows: [params.attempt] })); + const insert = vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })); + return { + select, + transaction, + execute, + insert, + getCapturedLedgerWhere: () => capturedLedgerWhere, + }; +} + +function launchingAttempt(overrides: Record = {}) { + return { + id: ATTEMPT_ID, + finding_id: FINDING_ID, + owned_by_organization_id: null, + owned_by_user_id: 'user-1', + remediation_id: REMEDIATION_ID, + origin: 'auto_policy', + analysis_fingerprint: 'fingerprint', + requested_by_user_id: 'user-1', + repo_full_name: 'kilo/repo', + remediation_model_slug: 'model', + branch_name: 'security-remediation/test-1', + status: 'launching', + queued_at: QUEUED_AT, + claim_token: 'claim-token', + claimed_at: QUEUED_AT, + claimed_by_job_id: 'job-1', + launch_attempt_count: 1, + next_retry_at: null, + attempt_number: 1, + priority: 50, + ...overrides, + }; +} + +describe('security remediation ledger settle with a missing finding', () => { + it('looks up the settle row by the attempt-derived user id', async () => { + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: {}, + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }); + + await settleSecurityRemediationLedgerRow({ + db: db as never, + finding: null, + attempt: { + id: ATTEMPT_ID, + queued_at: '2026-01-01T00:00:00.000Z', + owned_by_user_id: 'attempt-user-1', + owned_by_organization_id: null, + }, + terminalStatus: 'cancelled', + }); + + expect(extractEqPredicates(db.getCapturedLedgerWhere())).toEqual([ + { column: 'domain', value: 'security' }, + { column: 'kilo_user_id', value: 'attempt-user-1' }, + { column: 'intent', value: 'apply_auto_remediation' }, + { column: 'provider_ref', value: ATTEMPT_ID }, + ]); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('interrupted') + ); + }); +}); + +describe('cancelRemediation missing-finding settle', () => { + it('settles the admitted row as interrupted when the queued attempt finding is gone', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(null as never); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'attempt-user-1', + email: 'user@example.com', + name: 'User', + is_admin: false, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const attempt = await callbackAttempt({ + status: 'queued', + owned_by_user_id: 'attempt-user-1', + }); + const db = ledgerCapturingDb({ + attempt, + attempts: [attempt], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await cancelRemediation({ + env: { HYPERDRIVE: { connectionString: 'postgres://worker' } } as unknown as CloudflareEnv, + request: { + schemaVersion: 1, + attemptId: ATTEMPT_ID, + owner: { userId: 'attempt-user-1' }, + actorUserId: 'attempt-user-1', + }, + }); + + expect(result).toEqual({ success: true, status: 'cancelled' }); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('interrupted') + ); + expect(extractEqPredicates(db.getCapturedLedgerWhere())).toEqual([ + { column: 'domain', value: 'security' }, + { column: 'kilo_user_id', value: 'attempt-user-1' }, + { column: 'intent', value: 'apply_auto_remediation' }, + { column: 'provider_ref', value: ATTEMPT_ID }, + ]); + }); +}); + +describe('processRemediationAttempt finding-unavailable settle', () => { + it('settles the admitted row as superseded when the finding is gone before launch', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(null as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const attempt = launchingAttempt({ owned_by_user_id: 'attempt-user-1' }); + const db = ledgerCapturingDb({ + attempt, + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { HYPERDRIVE: { connectionString: 'postgres://worker' } } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('failed'); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('superseded') + ); + expect(extractEqPredicates(db.getCapturedLedgerWhere())).toEqual([ + { column: 'domain', value: 'security' }, + { column: 'kilo_user_id', value: 'attempt-user-1' }, + { column: 'intent', value: 'apply_auto_remediation' }, + { column: 'provider_ref', value: ATTEMPT_ID }, + ]); + }); +}); + +describe('processRemediationAttempt terminal launch-failure settle', () => { + it('settles the admitted row as failed when launch fails terminally', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue(null); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt(), + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: true }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { HYPERDRIVE: { connectionString: 'postgres://worker' } } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('failed'); + expect(settleOperation).toHaveBeenCalledWith(expect.anything(), settleCallMatcher('failed')); + }); +}); + +describe('processRemediationAttempt pre-initiation cancellation settle', () => { + it('settles the admitted row as interrupted when cancellation is requested before initiation', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + api_token_pepper: null, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const cloudAgentFetch = vi.fn(async () => + Response.json({ + result: { data: { cloudAgentSessionId: 'agent-session', kiloSessionId: 'ses-123' } }, + }) + ); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt(), + attempts: [ + { + id: ATTEMPT_ID, + status: 'launching', + analysisFingerprint: null, + cancellationRequestedAt: '2026-01-01T00:01:00.000Z', + }, + ], + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: true }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + ENVIRONMENT: 'development', + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'web', + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + CLOUD_AGENT_NEXT: { fetch: cloudAgentFetch }, + } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('launched'); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + settleCallMatcher('interrupted') + ); + }); +}); + +// ----- processRemediationAttempt lifecycle emit wiring ---------------------- + +const lifecycleEnv = { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: async () => 'internal-secret' }, +} as unknown as CloudflareEnv; + +function postedLifecycleEvents(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(call => { + const init = call[1] as RequestInit | undefined; + if (!init?.body) return null; + try { + return (JSON.parse(init.body as string) as { event?: string }).event ?? null; + } catch { + return null; + } + }) + .filter((event): event is string => event !== null); +} + +describe('processRemediationAttempt lifecycle emit wiring', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200 })) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits remediation_blocked when the attempt is blocked before launch', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt(), + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: false }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: lifecycleEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('skipped'); + expect(postedLifecycleEvents(vi.mocked(fetch))).toEqual(['remediation_blocked']); + }); + + it('emits remediation_failed when launch fails terminally', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue(null); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt(), + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: true }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: lifecycleEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('failed'); + expect(postedLifecycleEvents(vi.mocked(fetch))).toEqual(['remediation_failed']); + }); + + it('does not emit when a retryable launch failure re-queues the attempt', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + api_token_pepper: null, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt({ launch_attempt_count: 1 }), + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: true }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { + ...lifecycleEnv, + CLOUD_AGENT_NEXT: { + fetch: vi.fn(async () => { + throw new Error('upstream 5xx'); + }), + }, + } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('failed'); + expect(postedLifecycleEvents(vi.mocked(fetch))).toEqual([]); + }); + + it('emits remediation_failed when a retryable launch failure exhausts its attempts', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligibleAutoPolicyFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + api_token_pepper: null, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + + const db = ledgerCapturingDb({ + attempt: launchingAttempt({ launch_attempt_count: 3 }), + ledgerRows: [{ id: 'ledger-row-1' }], + userRows: [{ email: 'owner@example.com' }], + agentConfigs: [{ config: autoPolicyRuntimeConfig(), is_enabled: true }], + integrations: [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }], + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const result = await processRemediationAttempt({ + env: { + ...lifecycleEnv, + CLOUD_AGENT_NEXT: { + fetch: vi.fn(async () => { + throw new Error('upstream 5xx'); + }), + }, + } as unknown as CloudflareEnv, + attemptId: ATTEMPT_ID, + dispatchId: 'dispatch-1', + }); + + expect(result).toBe('failed'); + expect(postedLifecycleEvents(vi.mocked(fetch))).toEqual(['remediation_failed']); + }); +}); diff --git a/services/security-auto-analysis/src/remediation.test.ts b/services/security-auto-analysis/src/remediation.test.ts index 03b2300560..2bd67493dd 100644 --- a/services/security-auto-analysis/src/remediation.test.ts +++ b/services/security-auto-analysis/src/remediation.test.ts @@ -1,16 +1,63 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorkerDb } from '@kilocode/db/client'; +import type * as DbClientModule from '@kilocode/db/client'; +import { + admitOperation, + recordOperationAcceptance, + settleOperation, +} from '@kilocode/db/operation-ledger'; +import { + agent_configs, + kilocode_users, + operation_ledgers, + platform_integrations, + security_findings, + security_remediation_attempts, +} from '@kilocode/db/schema'; +import type * as SecurityFindingAuditModule from '@kilocode/worker-utils/security-finding-audit'; import type * as QueriesModule from './db/queries.js'; -import { getSecurityFindingById } from './db/queries.js'; +import { + getAnalysisActorById, + getSecurityFindingById, + resolveAutoAnalysisActor, +} from './db/queries.js'; import { logger } from './logger.js'; import { admitRemediationAttempt, + applyAutoRemediationCommand, buildRemediationPrepareSessionBody, buildRemediationPrompt, + cancelRemediation, + dispatchSecurityLifecycleEventForFinding, + finalizeRemediationCallbackFromEnv, + maybeAdmitAutoRemediationForCompletedAnalysis, + remediationTerminalLifecycleEvent, + startManualRemediation, } from './remediation.js'; +import { DEFAULT_SECURITY_AGENT_CONFIG } from './types.js'; vi.mock('./db/queries.js', async importOriginal => ({ ...(await importOriginal()), getSecurityFindingById: vi.fn(), + getAnalysisActorById: vi.fn(), + resolveAutoAnalysisActor: vi.fn(), +})); + +vi.mock('@kilocode/db/client', async importOriginal => ({ + ...(await importOriginal()), + getWorkerDb: vi.fn(), +})); + +vi.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: vi.fn(), + recordOperationAcceptance: vi.fn(), + settleOperation: vi.fn(), +})); + +vi.mock('@kilocode/worker-utils/security-finding-audit', async importOriginal => ({ + ...(await importOriginal()), + insertSecurityFindingAuditEvent: vi.fn(), })); beforeEach(() => { @@ -41,6 +88,106 @@ describe('security remediation admission', () => { }); }); +describe('security remediation approval gate', () => { + const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + + const validFinding = { + id: findingId, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + status: 'open', + severity: 'high', + package_name: 'lodash', + package_ecosystem: 'npm', + dependency_scope: 'runtime', + cve_id: null, + ghsa_id: null, + cwe_ids: null, + cvss_score: null, + title: 'Command Injection in lodash', + description: null, + vulnerable_version_range: '< 4.17.21', + patched_version: '4.17.21', + manifest_path: 'package.json', + raw_data: { updated_at: '2026-01-01T00:00:00.000Z' }, + last_synced_at: '2026-01-02T00:00:00.000Z', + analysis_status: 'completed', + analysis_completed_at: '2026-01-02T00:05:00.000Z', + analysis: { + analyzedAt: '2026-01-02T00:05:00.000Z', + sandboxAnalysis: { + isExploitable: true, + suggestedAction: 'open_pr', + suggestedFix: 'Upgrade lodash to 4.17.21', + usageLocations: [], + summary: 'Reachable vulnerable lodash usage', + rawMarkdown: '', + analysisAt: '2026-01-02T00:05:00.000Z', + }, + }, + }; + + const emptyAttemptsDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve([])), + })), + })), + }; + + function approvalRequiredRuntimeConfig() { + return { + config: { + ...DEFAULT_SECURITY_AGENT_CONFIG, + auto_remediation_enabled: true, + auto_remediation_require_approval: true, + }, + isAgentEnabled: true, + repoFullNamesInScope: ['kilo/repo'], + }; + } + + it('rejects auto_policy admission with approval_required when approval is required', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(validFinding as never); + + await expect( + admitRemediationAttempt({ + db: emptyAttemptsDb as never, + findingId, + origin: 'auto_policy', + owner: { type: 'user', id: 'user-1' }, + runtimeConfig: approvalRequiredRuntimeConfig(), + }) + ).resolves.toEqual({ admitted: false, reason: 'approval_required' }); + }); + + it('never rejects manual admission for the approval flag', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + ...validFinding, + analysis: { + ...validFinding.analysis, + sandboxAnalysis: { + ...validFinding.analysis.sandboxAnalysis, + suggestedAction: 'monitor', + }, + }, + } as never); + + await expect( + admitRemediationAttempt({ + db: emptyAttemptsDb as never, + findingId, + origin: 'manual', + owner: { type: 'user', id: 'user-1' }, + runtimeConfig: approvalRequiredRuntimeConfig(), + }) + ).resolves.toEqual({ admitted: false, reason: 'monitor_required' }); + }); +}); + describe('security remediation launch contract', () => { it('does not pass the new remediation branch as upstream checkout branch', () => { const body = buildRemediationPrepareSessionBody({ @@ -97,3 +244,802 @@ describe('security remediation launch contract', () => { ); }); }); + +describe('security lifecycle event mapping', () => { + it.each([ + ['pr_opened', 'remediation_pr_opened'], + ['failed', 'remediation_failed'], + ['blocked', 'remediation_blocked'], + ['no_changes_needed', 'remediation_no_changes_needed'], + ['cancelled', 'remediation_cancelled'], + ])('maps terminal status %s to %s', (status, event) => { + expect(remediationTerminalLifecycleEvent(status)).toBe(event); + }); + + it('returns null for non-terminal statuses', () => { + expect(remediationTerminalLifecycleEvent('queued')).toBeNull(); + expect(remediationTerminalLifecycleEvent('running')).toBeNull(); + expect(remediationTerminalLifecycleEvent('launching')).toBeNull(); + }); +}); + +describe('dispatchSecurityLifecycleEventForFinding', () => { + const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts the lifecycle body for a personal finding', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + } as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: {} as never, + findingId, + event: 'remediation_queued', + remediationId: 'remediation-1', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.kilo.ai/api/internal/security-agent/notifications'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['X-Internal-Secret']).toBe('internal-secret'); + expect(JSON.parse(init.body as string)).toEqual({ + event: 'remediation_queued', + findingId, + scope: 'personal', + remediationId: 'remediation-1', + recipientUserIds: ['user-1'], + }); + }); + + it('resolves org owners and posts the org scope', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + owned_by_user_id: null, + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + } as never); + const db = { + select: () => ({ + from: () => ({ + where: () => + Promise.resolve([{ userId: 'owner-1' }, { userId: 'owner-2' }, { userId: 'owner-1' }]), + }), + }), + }; + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: db as never, + findingId, + event: 'remediation_pr_opened', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + event: 'remediation_pr_opened', + findingId, + scope: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + remediationId: 'remediation-1', + prUrl: 'https://github.com/acme/api/pull/42', + recipientUserIds: ['owner-1', 'owner-2'], + }); + }); + + it('never throws when the POST fails', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + } as never); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); + + await expect( + dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: {} as never, + findingId, + event: 'analysis_completed', + }) + ).resolves.toBeUndefined(); + }); + + it('logs a warning when the POST returns a non-OK status', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + } as never); + const warn = vi.spyOn(logger, 'warn'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + + await expect( + dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: {} as never, + findingId, + event: 'analysis_completed', + }) + ).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalledWith('Security lifecycle push dispatch returned non-OK status', { + finding_id: findingId, + event: 'analysis_completed', + status: 401, + }); + }); + + it('does not POST when the finding is missing', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(null as never); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: {} as never, + findingId, + event: 'analysis_completed', + }) + ).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not POST when the recipient list resolves empty', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + owned_by_user_id: null, + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + } as never); + const db = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve([]), + }), + }), + }; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + dispatchSecurityLifecycleEventForFinding({ + env: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as never, + db: db as never, + findingId, + event: 'analysis_completed', + }) + ).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('finalizeRemediationCallbackFromEnv lifecycle emit sites', () => { + const ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const REMEDIATION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const ATTEMPT_TOKEN = 'attempt-token-123'; + + const env = { + HYPERDRIVE: { connectionString: 'postgres://test' }, + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + } as unknown as CloudflareEnv; + + const personalFinding = { + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + } as never; + + function sha256Hex(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + function buildAttempt(overrides: Record = {}) { + return { + id: ATTEMPT_ID, + finding_id: FINDING_ID, + remediation_id: REMEDIATION_ID, + callback_attempt_token_hash: sha256Hex(ATTEMPT_TOKEN), + cloud_agent_session_id: 'agent-123', + status: 'running', + cancellation_requested_at: null, + requested_by_user_id: null, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + queued_at: '2026-01-01T00:00:00.000Z', + origin: 'auto_policy', + remediation_model_slug: 'anthropic/claude-opus-4.6', + branch_name: 'security-remediation/package-advisory/abc123-1', + ...overrides, + }; + } + + function createFinalizeDb(attempt: Record | null) { + const select = vi + .fn() + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => (attempt ? [attempt] : []), + }), + }), + }) + .mockReturnValue({ + from: () => ({ + where: () => ({ + limit: async () => [], + }), + }), + }); + const tx = { + update: () => ({ + set: () => ({ + where: async () => undefined, + }), + }), + }; + return { + select, + transaction: async (fn: (t: typeof tx) => Promise) => fn(tx), + }; + } + + function eventFromFetch(fetchMock: ReturnType): string { + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + return (JSON.parse(init.body as string) as { event: string }).event; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits remediation_cancelled when an interrupted attempt was cancelled', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(getWorkerDb).mockReturnValue( + createFinalizeDb( + buildAttempt({ cancellation_requested_at: '2026-01-02T00:00:00.000Z' }) + ) as never + ); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'interrupted', + }, + }) + ).resolves.toEqual({ status: 'cancelled-finalized' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(eventFromFetch(fetchMock)).toBe('remediation_cancelled'); + }); + + it('emits remediation_failed when an interrupted attempt was not cancelled', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(getWorkerDb).mockReturnValue(createFinalizeDb(buildAttempt()) as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'interrupted', + errorMessage: 'Cloud Agent interrupted', + }, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + + expect(eventFromFetch(fetchMock)).toBe('remediation_failed'); + }); + + it('emits remediation_failed when the callback reports a failed attempt', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(getWorkerDb).mockReturnValue(createFinalizeDb(buildAttempt()) as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'failed', + errorMessage: 'Cloud Agent failed', + }, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + + expect(eventFromFetch(fetchMock)).toBe('remediation_failed'); + }); + + it('emits remediation_failed when the completed result block is missing', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(resolveAutoAnalysisActor).mockResolvedValue(null as never); + vi.mocked(getWorkerDb).mockReturnValue(createFinalizeDb(buildAttempt()) as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + }, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + + expect(eventFromFetch(fetchMock)).toBe('remediation_failed'); + }); + + it('emits the terminal event for a parsed completed disposition', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(getWorkerDb).mockReturnValue(createFinalizeDb(buildAttempt()) as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + lastAssistantMessageText: + 'SECURITY_REMEDIATION_RESULT\n{"status":"no_changes_needed"}\nEND_SECURITY_REMEDIATION_RESULT', + }, + }) + ).resolves.toEqual({ status: 'no_changes_needed-finalized' }); + + expect(eventFromFetch(fetchMock)).toBe('remediation_no_changes_needed'); + }); + + it('does not emit for a missing attempt', async () => { + vi.mocked(getWorkerDb).mockReturnValue(createFinalizeDb(null) as never); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + }, + }) + ).resolves.toEqual({ status: 'missing' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not emit for an already-terminal attempt', async () => { + vi.mocked(getWorkerDb).mockReturnValue( + createFinalizeDb(buildAttempt({ status: 'failed' })) as never + ); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + finalizeRemediationCallbackFromEnv({ + env, + attemptId: ATTEMPT_ID, + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + }, + }) + ).resolves.toEqual({ status: 'already-terminal' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('remediation queued lifecycle emit sites', () => { + const ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const REMEDIATION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const COMMAND_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + + const env = { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + REMEDIATION_ATTEMPT_QUEUE: { sendBatch: vi.fn().mockResolvedValue(undefined) }, + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as unknown as CloudflareEnv; + + const actor = { + id: 'user-1', + email: 'user@example.com', + name: 'User', + api_token_pepper: null, + is_admin: false, + } as never; + + function eligiblePersonalFinding() { + return { + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + status: 'open', + severity: 'high', + package_name: 'lodash', + package_ecosystem: 'npm', + dependency_scope: 'runtime', + cve_id: null, + ghsa_id: null, + cwe_ids: null, + cvss_score: null, + title: 'Command Injection in lodash', + description: null, + vulnerable_version_range: '< 4.17.21', + patched_version: '4.17.21', + manifest_path: 'package.json', + raw_data: { updated_at: '2026-01-01T00:00:00.000Z' }, + last_synced_at: '2026-01-02T00:00:00.000Z', + analysis_status: 'completed', + analysis_completed_at: '2026-01-02T00:05:00.000Z', + analysis: { + analyzedAt: '2026-01-02T00:05:00.000Z', + sandboxAnalysis: { + isExploitable: true, + suggestedAction: 'open_pr', + suggestedFix: 'Upgrade lodash to 4.17.21', + usageLocations: [], + summary: 'Reachable vulnerable lodash usage', + rawMarkdown: '', + analysisAt: '2026-01-02T00:05:00.000Z', + }, + }, + } as never; + } + + function autoPolicyRuntimeConfig() { + return { + ...DEFAULT_SECURITY_AGENT_CONFIG, + auto_remediation_enabled: true, + auto_remediation_require_approval: false, + auto_remediation_enabled_at: '2026-01-01T00:00:00.000Z', + repository_selection_mode: 'all', + }; + } + + function bulkExistingRuntimeConfig() { + return { + ...DEFAULT_SECURITY_AGENT_CONFIG, + auto_remediation_enabled: true, + auto_remediation_require_approval: false, + auto_remediation_include_existing: true, + repository_selection_mode: 'all', + }; + } + + /** A thenable drizzle result that also supports `.limit()` and `.orderBy()`. */ + function thenable(rows: unknown[]) { + return { + then: (resolve: (value: unknown) => void) => resolve(rows), + limit: () => Promise.resolve(rows), + orderBy: () => thenable(rows), + }; + } + + function admissionDb(options: { + runtimeConfig?: unknown; + attempts?: unknown[]; + candidateFindings?: unknown[]; + ledgerRows?: unknown[]; + userRows?: unknown[]; + }) { + const rowsFor = (table: unknown): unknown[] => { + if (table === agent_configs) + return [{ config: options.runtimeConfig ?? {}, is_enabled: true }]; + if (table === platform_integrations) + return [{ repositories: [{ id: 1, full_name: 'kilo/repo' }] }]; + if (table === security_remediation_attempts) return options.attempts ?? []; + if (table === security_findings) return options.candidateFindings ?? []; + if (table === operation_ledgers) return options.ledgerRows ?? []; + if (table === kilocode_users) return options.userRows ?? []; + return []; + }; + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => thenable(rowsFor(table))), + })), + })); + const tx = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => ({ + returning: vi.fn(() => Promise.resolve([{ id: REMEDIATION_ID }])), + })), + returning: vi.fn(() => + Promise.resolve([ + { + id: ATTEMPT_ID, + remediation_id: REMEDIATION_ID, + finding_id: FINDING_ID, + origin: 'manual', + requested_by_user_id: 'user-1', + remediation_model_slug: 'model', + branch_name: 'security-remediation/test-1', + attempt_number: 1, + }, + ]) + ), + })), + })), + select: vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => thenable(rowsFor(table))), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => thenable([])), + })), + })), + }; + const transaction = vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)); + const update = vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(() => Promise.resolve([])), + })), + })), + })); + return { select, transaction, update }; + } + + function queuedEventFromFetch(fetchMock: ReturnType) { + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + return JSON.parse(init.body as string) as { event: string; remediationId: string }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits remediation_queued from startManualRemediation', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligiblePersonalFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue(actor); + vi.mocked(getWorkerDb).mockReturnValue( + admissionDb({ runtimeConfig: autoPolicyRuntimeConfig() }) as never + ); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await startManualRemediation({ + env, + request: { + schemaVersion: 1, + findingId: FINDING_ID, + owner: { userId: 'user-1' }, + actorUserId: 'user-1', + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(queuedEventFromFetch(fetchMock)).toMatchObject({ + event: 'remediation_queued', + remediationId: REMEDIATION_ID, + }); + }); + + it('emits remediation_queued from applyAutoRemediationCommand', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligiblePersonalFinding()); + vi.mocked(getAnalysisActorById).mockResolvedValue(actor); + vi.mocked(getWorkerDb).mockReturnValue( + admissionDb({ + runtimeConfig: bulkExistingRuntimeConfig(), + candidateFindings: [{ id: FINDING_ID }], + }) as never + ); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await applyAutoRemediationCommand({ + env, + command: { + schemaVersion: 1, + commandId: COMMAND_ID, + owner: { userId: 'user-1' }, + actorUserId: 'user-1', + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(queuedEventFromFetch(fetchMock)).toMatchObject({ + event: 'remediation_queued', + remediationId: REMEDIATION_ID, + }); + }); + + it('emits remediation_queued from maybeAdmitAutoRemediationForCompletedAnalysis', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(eligiblePersonalFinding()); + vi.mocked(admitOperation).mockResolvedValue({ + admission: 'admitted', + row: { id: 'ledger-row-1' }, + } as never); + vi.mocked(recordOperationAcceptance).mockResolvedValue({} as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await maybeAdmitAutoRemediationForCompletedAnalysis({ + db: admissionDb({ runtimeConfig: autoPolicyRuntimeConfig() }) as never, + env, + findingId: FINDING_ID, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(queuedEventFromFetch(fetchMock)).toMatchObject({ + event: 'remediation_queued', + remediationId: REMEDIATION_ID, + }); + }); +}); + +describe('cancelRemediation queued-cancel lifecycle emit', () => { + const ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const REMEDIATION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + + const env = { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + INTERNAL_API_SECRET: { get: vi.fn().mockResolvedValue('internal-secret') }, + } as unknown as CloudflareEnv; + + const personalFinding = { + id: FINDING_ID, + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + } as never; + + function queuedAttempt() { + return { + id: ATTEMPT_ID, + finding_id: FINDING_ID, + remediation_id: REMEDIATION_ID, + status: 'queued', + owned_by_user_id: 'user-1', + owned_by_organization_id: null, + queued_at: '2026-01-01T00:00:00.000Z', + origin: 'manual', + requested_by_user_id: 'user-1', + remediation_model_slug: 'model', + branch_name: 'security-remediation/test-1', + }; + } + + function cancelDb() { + const rowsFor = (table: unknown): unknown[] => { + if (table === security_remediation_attempts) return [queuedAttempt()]; + if (table === operation_ledgers) return [{ id: 'ledger-row-1' }]; + if (table === kilocode_users) return [{ email: 'owner@example.com' }]; + return []; + }; + const select = vi.fn(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve(rowsFor(table))), + })), + })), + })); + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + }; + const transaction = vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)); + return { select, transaction }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits remediation_cancelled when a queued attempt is cancelled', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(personalFinding); + vi.mocked(getAnalysisActorById).mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + api_token_pepper: null, + is_admin: false, + } as never); + vi.mocked(settleOperation).mockResolvedValue({ + settled: true, + row: { id: 'ledger-row-1' }, + } as never); + vi.mocked(getWorkerDb).mockReturnValue(cancelDb() as never); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await cancelRemediation({ + env, + request: { + schemaVersion: 1, + attemptId: ATTEMPT_ID, + owner: { userId: 'user-1' }, + actorUserId: 'user-1', + }, + }); + + expect(result).toEqual({ success: true, status: 'cancelled' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + event: 'remediation_cancelled', + remediationId: REMEDIATION_ID, + }); + }); +}); diff --git a/services/security-auto-analysis/src/remediation.ts b/services/security-auto-analysis/src/remediation.ts index 2eedaaac68..6dbed42001 100644 --- a/services/security-auto-analysis/src/remediation.ts +++ b/services/security-auto-analysis/src/remediation.ts @@ -3,6 +3,10 @@ import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; import { transitionSecurityAgentCommand } from '@kilocode/db'; import { agent_configs, + kilocode_users, + operation_ledgers, + organization_memberships, + organizations, platform_integrations, security_audit_log, security_findings, @@ -11,6 +15,12 @@ import { type NewSecurityRemediationAttempt, type SecurityRemediationAttempt, } from '@kilocode/db/schema'; +import { + admitOperation, + recordOperationAcceptance, + settleOperation, + type TerminalOperationStatus, +} from '@kilocode/db/operation-ledger'; import { SecurityAuditLogAction, SecurityFindingAuditSourceContext, @@ -907,7 +917,7 @@ async function transitionAttemptLaunchFailure(params: { failureCode: string; errorMessage: string; retryable: boolean; -}): Promise { +}): Promise { const terminal = !params.retryable || params.attempt.launch_attempt_count >= REMEDIATION_LAUNCH_MAX_ATTEMPTS; await params.db.transaction(async tx => { @@ -955,6 +965,15 @@ async function transitionAttemptLaunchFailure(params: { } } }); + if (terminal && params.finding) { + await settleSecurityRemediationLedgerRow({ + db: params.db, + finding: params.finding, + attempt: params.attempt, + terminalStatus: 'failed', + }); + } + return terminal; } async function blockAttempt(params: { @@ -998,6 +1017,12 @@ async function blockAttempt(params: { }); } }); + await settleSecurityRemediationLedgerRow({ + db: params.db, + finding: params.finding ?? null, + attempt: params.attempt, + terminalStatus: 'blocked', + }); } async function samePackageOpenPrExists(params: { @@ -1095,6 +1120,12 @@ async function finalizeAttemptCancellation(params: { finalAssistantMessage: undefined, actor: SECURITY_FINDING_AUDIT_SYSTEM_ACTOR, }); + await settleSecurityRemediationLedgerRow({ + db: params.db, + finding: params.finding, + attempt: params.attempt, + terminalStatus: 'cancelled', + }); } function buildRemediationCallbackTarget( @@ -1299,6 +1330,12 @@ export async function processRemediationAttempt(params: { reason: decision.reason.toUpperCase(), summary: `Remediation no longer eligible: ${decision.reason}`, }); + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: 'blocked', + }); return 'skipped'; } if ( @@ -1312,6 +1349,12 @@ export async function processRemediationAttempt(params: { reason: 'COVERED_BY_EXISTING_REMEDIATION_PR', summary: 'Another open remediation PR covers same package and manifest', }); + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: 'blocked', + }); return 'skipped'; } @@ -1325,9 +1368,9 @@ export async function processRemediationAttempt(params: { errorMessage: 'Remediation actor unavailable', retryable: false, }); + await emitRemediationTerminalLifecycleEvent({ env: params.env, db, attempt, status: 'failed' }); return 'failed'; } - try { await launchAttempt({ db, env: params.env, attempt, finding, owner, actor }); try { @@ -1352,7 +1395,7 @@ export async function processRemediationAttempt(params: { } return 'launched'; } catch (error) { - await transitionAttemptLaunchFailure({ + const terminal = await transitionAttemptLaunchFailure({ db, attempt, finding, @@ -1361,6 +1404,14 @@ export async function processRemediationAttempt(params: { errorMessage: error instanceof Error ? error.message : String(error), retryable: !(error instanceof InsufficientCreditsError), }); + if (terminal) { + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: 'failed', + }); + } return 'failed'; } } @@ -1629,6 +1680,372 @@ async function finalizeAttemptAsFailed(params: { }); } +// ----- security remediation operation ledger (P1-A-07c) --------------------- + +const SECURITY_REMEDIATION_LEDGER_LEASE_SECONDS = 120; + +/** + * The ledger user id for a remediation attempt. Personal scope uses the + * finding's owner user. Org scope has no single acting user, so it + * approximates with the organization's `created_by_kilo_user_id` (a nullable + * column). Returns null when the org has no + * creator, in which case the admit is skipped and the terminal settle later + * skips on the missing row. + */ +async function resolveRemediationLedgerUserId( + db: WorkerDb, + finding: SecurityFindingRecord +): Promise { + if (finding.owned_by_user_id) return finding.owned_by_user_id; + if (!finding.owned_by_organization_id) return null; + const [org] = await db + .select({ createdBy: organizations.created_by_kilo_user_id }) + .from(organizations) + .where(eq(organizations.id, finding.owned_by_organization_id)) + .limit(1); + return org?.createdBy ?? null; +} + +/** + * The ledger user id for a remediation attempt whose finding is gone + * (account deletion hard-deletes `security_findings` but not the attempt). + * Personal scope uses the attempt's owner user; org scope approximates with + * the organization's `created_by_kilo_user_id`. + */ +async function resolveRemediationLedgerUserIdFromAttempt( + db: WorkerDb, + attempt: Pick +): Promise { + if (attempt.owned_by_user_id) return attempt.owned_by_user_id; + if (!attempt.owned_by_organization_id) return null; + const [org] = await db + .select({ createdBy: organizations.created_by_kilo_user_id }) + .from(organizations) + .where(eq(organizations.id, attempt.owned_by_organization_id)) + .limit(1); + return org?.createdBy ?? null; +} + +async function resolveRemediationLedgerDistinctId(db: WorkerDb, userId: string): Promise { + const [user] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)) + .limit(1); + return user?.email ?? userId; +} + +function remediationLedgerResourceKey(finding: SecurityFindingRecord): string { + const scope = finding.owned_by_organization_id + ? `org:${finding.owned_by_organization_id}` + : `user:${finding.owned_by_user_id}`; + return `security:apply_auto_remediation:${scope}:${finding.id}`; +} + +/** + * Admits a `security`-domain ledger row for an already-committed remediation + * attempt and records the attempt id as the provider reference. Best-effort: + * the attempt already committed, so a ledger write failure must not fail the + * remediation admission; the terminal settle then skips on the missing row. + */ +export async function admitSecurityRemediationLedgerRow(params: { + db: WorkerDb; + finding: SecurityFindingRecord; + attemptId: string; + remediationId: string; + attemptNumber: number; +}): Promise { + try { + const userId = await resolveRemediationLedgerUserId(params.db, params.finding); + if (!userId) { + logger.info('Skipping security remediation ledger admission: no ledger user id', { + attempt_id: params.attemptId, + }); + return; + } + const admission = await admitOperation(params.db, { + userId, + orgId: params.finding.owned_by_organization_id ?? null, + domain: 'security', + intent: 'apply_auto_remediation', + operationKey: `remediation:${params.attemptId}`, + resourceKey: remediationLedgerResourceKey(params.finding), + taxonomy: 'reconcile-first', + leaseSeconds: SECURITY_REMEDIATION_LEDGER_LEASE_SECONDS, + }); + if (admission.admission !== 'admitted') return; + await recordOperationAcceptance(params.db, { + rowId: admission.row.id, + providerRef: params.attemptId, + canonicalResult: { + attemptId: params.attemptId, + remediationId: params.remediationId, + attemptNumber: params.attemptNumber, + }, + }); + } catch (error) { + logger.error('Failed to admit the security remediation ledger row', { + attempt_id: params.attemptId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Maps a terminal attempt status to a terminal operation outcome. */ +function remediationTerminalOutcome(status: string): TerminalOperationStatus | null { + switch (status) { + case 'pr_opened': + return 'completed'; + case 'failed': + return 'failed'; + case 'no_changes_needed': + return 'no_op'; + case 'cancelled': + return 'interrupted'; + case 'blocked': + return 'superseded'; + default: + return null; + } +} + +/** + * Settles the remediation ledger row from the terminal callback. The row is + * joined by `provider_ref = attemptId` (the two-step pattern), then settled by + * row id. A missing admit row skips with a log and never throws. Best-effort: + * the attempt is already terminal, so a settle failure must not fail the + * callback (a retry would hit the already-terminal guard and never settle). + */ +export async function settleSecurityRemediationLedgerRow(params: { + db: WorkerDb; + finding: SecurityFindingRecord | null; + attempt: Pick< + SecurityRemediationAttempt, + 'id' | 'queued_at' | 'owned_by_user_id' | 'owned_by_organization_id' + >; + terminalStatus: string; +}): Promise { + const outcome = remediationTerminalOutcome(params.terminalStatus); + if (!outcome) return; + try { + const userId = params.finding + ? await resolveRemediationLedgerUserId(params.db, params.finding) + : await resolveRemediationLedgerUserIdFromAttempt(params.db, params.attempt); + if (!userId) { + logger.info('Skipping security remediation ledger settle: no ledger user id', { + attempt_id: params.attempt.id, + }); + return; + } + const [row] = await params.db + .select({ id: operation_ledgers.id }) + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.domain, 'security'), + eq(operation_ledgers.kilo_user_id, userId), + eq(operation_ledgers.intent, 'apply_auto_remediation'), + eq(operation_ledgers.provider_ref, params.attempt.id) + ) + ) + .limit(1); + if (!row) { + logger.info('Skipping security remediation ledger settle: missing admit row', { + attempt_id: params.attempt.id, + }); + return; + } + const distinctId = await resolveRemediationLedgerDistinctId(params.db, userId); + await settleOperation(params.db, { + rowId: row.id, + status: outcome, + outboxEvent: { + eventName: 'security_command_settled', + distinctId, + properties: { + source: 'server', + surface: 'security', + phase: 'terminal', + intent: 'apply_auto_remediation', + outcome, + duration_ms: Math.max(0, Date.now() - new Date(params.attempt.queued_at).getTime()), + }, + }, + }); + } catch (error) { + logger.error('Failed to settle the security remediation ledger row', { + attempt_id: params.attempt.id, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Settles an admitted ledger row after a terminal transition that happens + * outside the callback. Fetches the attempt's queued_at for the duration + * metric, then settles by row id. Best-effort: never throws, so it cannot + * mask the original terminal-transition error. + */ +async function settleAdmittedLedgerRowBestEffort(params: { + db: WorkerDb; + finding: SecurityFindingRecord; + attemptId: string; + terminalStatus: string; +}): Promise { + try { + const [attempt] = await params.db + .select({ + id: security_remediation_attempts.id, + queued_at: security_remediation_attempts.queued_at, + owned_by_user_id: security_remediation_attempts.owned_by_user_id, + owned_by_organization_id: security_remediation_attempts.owned_by_organization_id, + }) + .from(security_remediation_attempts) + .where(eq(security_remediation_attempts.id, params.attemptId)) + .limit(1); + if (!attempt) return; + await settleSecurityRemediationLedgerRow({ + db: params.db, + finding: params.finding, + attempt, + terminalStatus: params.terminalStatus, + }); + } catch (error) { + logger.error('Failed to settle the security remediation ledger row after terminal transition', { + attempt_id: params.attemptId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +// ----- security lifecycle push producers (P2-GH-48b) --------------------- +// +// These emit the `security_lifecycle` push after a terminal persist commits. +// They run post-commit and best-effort: a push failure must never roll back +// or fail the persist, so every path below catches and logs. + +// 1:1 map to the `security_lifecycle` event enum in +// `packages/notifications/src/push-data.ts`. The service cannot import that +// package, so the union is declared here. +export type SecurityLifecycleEvent = + | 'analysis_completed' + | 'analysis_failed' + | 'remediation_queued' + | 'remediation_pr_opened' + | 'remediation_failed' + | 'remediation_blocked' + | 'remediation_no_changes_needed' + | 'remediation_cancelled'; + +/** Maps a terminal remediation attempt status to its lifecycle push event. */ +export function remediationTerminalLifecycleEvent(status: string): SecurityLifecycleEvent | null { + switch (status) { + case 'pr_opened': + return 'remediation_pr_opened'; + case 'failed': + return 'remediation_failed'; + case 'blocked': + return 'remediation_blocked'; + case 'no_changes_needed': + return 'remediation_no_changes_needed'; + case 'cancelled': + return 'remediation_cancelled'; + default: + return null; + } +} + +async function resolveSecurityLifecycleRecipientUserIds( + db: WorkerDb, + finding: SecurityFindingRecord +): Promise { + if (finding.owned_by_user_id) return [finding.owned_by_user_id]; + if (!finding.owned_by_organization_id) return []; + const rows = await db + .select({ userId: organization_memberships.kilo_user_id }) + .from(organization_memberships) + .where( + and( + eq(organization_memberships.organization_id, finding.owned_by_organization_id), + eq(organization_memberships.role, 'owner') + ) + ); + return [...new Set(rows.map(row => row.userId))]; +} + +/** + * Best-effort, post-commit security lifecycle push. Resolves recipients + * (personal scope → the owner user; org scope → org owners, mirroring the + * finding push), then POSTs the web internal notifications route. Never + * throws: a push failure must not roll back or fail the terminal persist. + */ +export async function dispatchSecurityLifecycleEventForFinding(params: { + env: CloudflareEnv; + db: WorkerDb; + findingId: string; + event: SecurityLifecycleEvent; + remediationId?: string; + prUrl?: string; +}): Promise { + try { + const finding = await getSecurityFindingById(params.db, params.findingId); + if (!finding) return; + const recipientUserIds = await resolveSecurityLifecycleRecipientUserIds(params.db, finding); + if (recipientUserIds.length === 0) return; + const backendUrl = params.env.KILOCODE_BACKEND_BASE_URL; + const internalSecret = await params.env.INTERNAL_API_SECRET.get(); + if (!backendUrl || !internalSecret) return; + const response = await fetch(`${backendUrl}/api/internal/security-agent/notifications`, { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + 'Content-Type': 'application/json', + 'X-Internal-Secret': internalSecret, + }, + body: JSON.stringify({ + event: params.event, + findingId: params.findingId, + scope: finding.owned_by_organization_id ?? 'personal', + ...(params.remediationId !== undefined ? { remediationId: params.remediationId } : {}), + ...(params.prUrl !== undefined ? { prUrl: params.prUrl } : {}), + recipientUserIds, + }), + }); + if (!response.ok) { + logger.warn('Security lifecycle push dispatch returned non-OK status', { + finding_id: params.findingId, + event: params.event, + status: response.status, + }); + } + } catch (error) { + logger.warn('Security lifecycle push dispatch failed', { + finding_id: params.findingId, + event: params.event, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +async function emitRemediationTerminalLifecycleEvent(params: { + env: CloudflareEnv; + db: WorkerDb; + attempt: SecurityRemediationAttempt; + status: string; + prUrl?: string | null; +}): Promise { + const event = remediationTerminalLifecycleEvent(params.status); + if (!event) return; + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db: params.db, + findingId: params.attempt.finding_id, + event, + remediationId: params.attempt.remediation_id, + prUrl: params.prUrl ?? undefined, + }); +} + export async function finalizeRemediationCallbackFromEnv(params: { env: CloudflareEnv; attemptId: string; @@ -1676,6 +2093,18 @@ export async function finalizeRemediationCallbackFromEnv(params: { finalAssistantMessage: params.payload.lastAssistantMessageText, actor: SECURITY_FINDING_AUDIT_SYSTEM_ACTOR, }); + await settleSecurityRemediationLedgerRow({ + db, + finding, + attempt, + terminalStatus: 'cancelled', + }); + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: 'cancelled', + }); return { status: 'cancelled-finalized' }; } await finalizeAttemptAsFailed({ @@ -1685,6 +2114,8 @@ export async function finalizeRemediationCallbackFromEnv(params: { failureCode: 'CLOUD_AGENT_INTERRUPTED', message: params.payload.errorMessage ?? 'Cloud Agent interrupted', }); + await settleSecurityRemediationLedgerRow({ db, finding, attempt, terminalStatus: 'failed' }); + await emitRemediationTerminalLifecycleEvent({ env: params.env, db, attempt, status: 'failed' }); return { status: 'failed-finalized' }; } @@ -1696,6 +2127,8 @@ export async function finalizeRemediationCallbackFromEnv(params: { failureCode: 'CLOUD_AGENT_FAILED', message: params.payload.errorMessage ?? 'Cloud Agent failed', }); + await settleSecurityRemediationLedgerRow({ db, finding, attempt, terminalStatus: 'failed' }); + await emitRemediationTerminalLifecycleEvent({ env: params.env, db, attempt, status: 'failed' }); return { status: 'failed-finalized' }; } @@ -1717,6 +2150,8 @@ export async function finalizeRemediationCallbackFromEnv(params: { ? 'Remediation result PR could not be verified' : 'Remediation result block missing or malformed', }); + await settleSecurityRemediationLedgerRow({ db, finding, attempt, terminalStatus: 'failed' }); + await emitRemediationTerminalLifecycleEvent({ env: params.env, db, attempt, status: 'failed' }); return { status: 'failed-finalized' }; } await finalizeAttemptOutcome({ @@ -1727,6 +2162,14 @@ export async function finalizeRemediationCallbackFromEnv(params: { finalAssistantMessage: params.payload.lastAssistantMessageText, actor: SECURITY_FINDING_AUDIT_SYSTEM_ACTOR, }); + await settleSecurityRemediationLedgerRow({ db, finding, attempt, terminalStatus: result.status }); + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: result.status, + prUrl: result.prUrl, + }); return { status: `${result.status}-finalized` }; } @@ -1760,6 +2203,13 @@ export async function startManualRemediation(params: { await markAttemptQueueAdmissionFailed(db, result.attemptId); throw error; } + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db, + findingId: params.request.findingId, + event: 'remediation_queued', + remediationId: result.remediationId, + }); if (params.request.retry) { const finding = await getSecurityFindingById(db, params.request.findingId); if (finding) { @@ -1832,6 +2282,7 @@ export async function applyAutoRemediationCommand(params: { scanLimit: APPLY_AUTO_REMEDIATION_SCAN_LIMIT, truncated, }; + const lifecycleDispatchPromises: Promise[] = []; for (const row of findings) { counts.scanned += 1; try { @@ -1847,6 +2298,17 @@ export async function applyAutoRemediationCommand(params: { if (result.admitted) { counts.admitted += 1; await enqueueRemediationAttempt(params.env, result.attemptId, params.command.commandId); + // Fire the push concurrently: the dispatcher is bounded by its own 10s + // abort, so a hanging backend must not serialize to scan-limit × 10s. + lifecycleDispatchPromises.push( + dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db, + findingId: row.id, + event: 'remediation_queued', + remediationId: result.remediationId, + }) + ); } else { counts.skipped += 1; } @@ -1859,6 +2321,7 @@ export async function applyAutoRemediationCommand(params: { }); } } + await Promise.all(lifecycleDispatchPromises); await transitionSecurityAgentCommand(db, { commandId: params.command.commandId, fromStatuses: ['accepted', 'running'], @@ -1880,12 +2343,37 @@ export async function maybeAdmitAutoRemediationForCompletedAnalysis(params: { origin: 'auto_policy', }); if (!result.admitted) return result; + const finding = await getSecurityFindingById(params.db, params.findingId); + if (finding) { + await admitSecurityRemediationLedgerRow({ + db: params.db, + finding, + attemptId: result.attemptId, + remediationId: result.remediationId, + attemptNumber: result.attemptNumber, + }); + } try { await enqueueRemediationAttempt(params.env, result.attemptId); } catch (error) { await markAttemptQueueAdmissionFailed(params.db, result.attemptId); + if (finding) { + await settleAdmittedLedgerRowBestEffort({ + db: params.db, + finding, + attemptId: result.attemptId, + terminalStatus: 'failed', + }); + } throw error; } + await dispatchSecurityLifecycleEventForFinding({ + env: params.env, + db: params.db, + findingId: params.findingId, + event: 'remediation_queued', + remediationId: result.remediationId, + }); return result; } @@ -1919,6 +2407,18 @@ export async function cancelRemediation(params: { finalAssistantMessage: undefined, actor: auditActor, }); + await settleSecurityRemediationLedgerRow({ + db, + finding, + attempt, + terminalStatus: 'cancelled', + }); + await emitRemediationTerminalLifecycleEvent({ + env: params.env, + db, + attempt, + status: 'cancelled', + }); } else { await db.transaction(async tx => { await tx @@ -1941,6 +2441,12 @@ export async function cancelRemediation(params: { }) .where(eq(security_remediations.id, attempt.remediation_id)); }); + await settleSecurityRemediationLedgerRow({ + db, + finding: null, + attempt, + terminalStatus: 'cancelled', + }); } return { success: true, status: 'cancelled' }; } diff --git a/services/security-auto-analysis/src/types.test.ts b/services/security-auto-analysis/src/types.test.ts index 0e7a6c6dff..952255c2ff 100644 --- a/services/security-auto-analysis/src/types.test.ts +++ b/services/security-auto-analysis/src/types.test.ts @@ -35,6 +35,10 @@ describe('DEFAULT_SECURITY_AGENT_CONFIG', () => { expect(DEFAULT_SECURITY_AGENT_CONFIG.analysis_mode).toBe('auto'); expect(DEFAULT_SECURITY_AGENT_CONFIG.auto_analysis_min_severity).toBe('high'); }); + + it('defaults auto-remediation approval to not required', () => { + expect(DEFAULT_SECURITY_AGENT_CONFIG.auto_remediation_require_approval).toBe(false); + }); }); describe('resolveSecurityAgentModels', () => { diff --git a/services/security-auto-analysis/src/types.ts b/services/security-auto-analysis/src/types.ts index 11dbb5626b..2214e60fa1 100644 --- a/services/security-auto-analysis/src/types.ts +++ b/services/security-auto-analysis/src/types.ts @@ -19,6 +19,7 @@ export const SecurityAgentConfigSchema = z auto_remediation_enabled: z.boolean().default(false), auto_remediation_min_severity: z.enum(['critical', 'high', 'medium', 'all']).default('high'), auto_remediation_include_existing: z.boolean().default(false), + auto_remediation_require_approval: z.boolean().default(false), auto_remediation_enabled_at: z.string().nullable().default(null), remediation_model_slug: z.string().optional(), }) @@ -42,6 +43,7 @@ export const DEFAULT_SECURITY_AGENT_CONFIG: SecurityAgentConfig = { auto_remediation_enabled: false, auto_remediation_min_severity: 'high', auto_remediation_include_existing: false, + auto_remediation_require_approval: false, auto_remediation_enabled_at: null, remediation_model_slug: 'anthropic/claude-opus-4.6', };