From ce92982c1739592855df49db6f20fe7cf4981ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 03:41:55 +0200 Subject: [PATCH 01/46] feat(security-agent): add single command-type authority with drift tests Export SECURITY_COMMAND_TYPES as the shared tuple and derive SecurityCommandType from it. Add an app-shared invalidation-scope test and an apps/web drift test asserting the db union matches the shared tuple. Type the web create-call literals and the SecurityAgentAdmissionAction redeclaration with the shared type. --- .../security-agent/SecurityAgentContext.tsx | 7 ++++-- .../security-agent-command-copy.ts | 7 +++--- .../security-agent/command-type-drift.test.ts | 24 +++++++++++++++++++ .../security-agent/db/security-commands.ts | 3 ++- .../lib/security-agent/db/security-config.ts | 3 ++- .../src/security-agent/commands.test.ts | 7 ++++++ .../app-shared/src/security-agent/commands.ts | 12 ++++++---- 7 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/lib/security-agent/command-type-drift.test.ts diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 95784a3e82..ace67a966d 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'; @@ -203,7 +206,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; 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/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/db/security-commands.ts b/apps/web/src/lib/security-agent/db/security-commands.ts index 0953aec478..b525ab28a3 100644 --- a/apps/web/src/lib/security-agent/db/security-commands.ts +++ b/apps/web/src/lib/security-agent/db/security-commands.ts @@ -7,6 +7,7 @@ import { 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 { @@ -60,7 +61,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.ts b/apps/web/src/lib/security-agent/db/security-config.ts index e1547a61ee..07b9a96490 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 { @@ -284,7 +285,7 @@ export async function saveSecurityAgentConfigWithRevision(params: { let existingRemediationCommandId: string | undefined; if (params.enqueueRemediation) { 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/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). From 25843ba380ae5a223b05fe3a6c8b0e59ed13644d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 03:41:59 +0200 Subject: [PATCH 02/46] feat(notifications): add typed security lifecycle push payload Add an additive security_lifecycle variant to pushDataSchema with an eight-value event enum mapped 1:1 to SecurityAuditLogAction. Route the variant to the existing security channel and add lifecycle lock-screen copy in both exhaustive presentation switches. --- packages/notifications/src/push-data.test.ts | 82 +++++++++++++++++++ packages/notifications/src/push-data.ts | 29 +++++++ .../src/push-presentation.test.ts | 17 ++++ .../notifications/src/push-presentation.ts | 2 + 4 files changed, 130 insertions(+) create mode 100644 packages/notifications/src/push-data.test.ts 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. From 3e6b20f5482a10dea31819d1282269aaa85db559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 03:42:00 +0200 Subject: [PATCH 03/46] feat(analytics): extend settled-outcome catalog for security and code review Extend SECURITY_INTENTS to the four command intents with an authority-linked SECURITY_INTENT_FOR_COMMAND_TYPE map. Add the code_review_settled terminal event with a privacy-minimal schema and add the code_review operation ledger domain. --- .../src/analytics/event-map.test.ts | 43 ++++++++++++++++++- .../app-shared/src/analytics/event-map.ts | 30 ++++++++++++- packages/db/src/operation-ledger.ts | 2 +- 3 files changed, 72 insertions(+), 3 deletions(-) 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/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 = [ From 4d1ae22ed1f0b0ac2b3a3de260a6c0ff73f47bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 03:57:47 +0200 Subject: [PATCH 04/46] feat(security-agent): add bounded batch command status procedure Add getCommandStatuses on the personal and org routers with a min(1).max(100) uuid-array input schema. Batch-fetch owner-scoped commands, omit unknown or foreign ids, and settle terminal commands through the existing ledger settle helper. Keep getCommandStatus for older mobile clients. --- .../src/lib/security-agent/core/schemas.ts | 5 + .../db/security-commands.test.ts | 25 +++++ .../security-agent/db/security-commands.ts | 9 ++ .../router/shared-handlers.test.ts | 105 +++++++++++++++++- .../security-agent/router/shared-handlers.ts | 26 +++++ .../organization-security-agent-router.ts | 3 + apps/web/src/routers/security-agent-router.ts | 3 + packages/db/src/index.ts | 1 + .../src/security-agent-command-repository.ts | 12 ++ 9 files changed, 188 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/security-agent/core/schemas.ts b/apps/web/src/lib/security-agent/core/schemas.ts index 9479a206cc..9f46a66ee3 100644 --- a/apps/web/src/lib/security-agent/core/schemas.ts +++ b/apps/web/src/lib/security-agent/core/schemas.ts @@ -207,6 +207,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 +241,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/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 b525ab28a3..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,6 +2,7 @@ import { db } from '@/lib/drizzle'; import { createSecurityAgentCommand, getSecurityAgentCommandForOwner, + getSecurityAgentCommandsForOwner, listActiveSecurityAgentCommandsForOwner, markSecurityAgentCommandQueueAdmissionFailed, type SecurityAgentCommandOwner, @@ -52,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 { 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..b459025460 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,7 +6,7 @@ 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'; @@ -68,6 +68,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, @@ -138,6 +139,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() })); @@ -1488,3 +1490,104 @@ 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); + }); +}); 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..0de2ca38ef 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, @@ -85,6 +86,7 @@ import { CancelRemediationInputSchema, GetAnalysisInputSchema, GetCommandStatusInputSchema, + GetCommandStatusesInputSchema, DeleteFindingsByRepoInputSchema, GetDashboardStatsInputSchema, TrackSecurityAgentUiInteractionInputSchema, @@ -100,6 +102,7 @@ import { type CancelRemediationInput, type GetAnalysisInput, type GetCommandStatusInput, + type GetCommandStatusesInput, type DeleteFindingsByRepoInput, type GetDashboardStatsInput, type TrackSecurityAgentUiInteractionInput, @@ -1905,6 +1908,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/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/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/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/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, From 4a3056a9a75ba9528f7e53ea148ba862e4a2891e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 04:26:27 +0200 Subject: [PATCH 05/46] feat(organizations): gate GitLab OAuth replacement to billing roles Require ORGANIZATION_BILLING_ROLES when an org already has a GitLab integration, on both the OAuth connect and callback paths; first-time connect keeps member access. Add a role matrix over every organization-security-agent procedure and map access denials to permission-specific error codes instead of connection errors. --- .../integrations/GitLabIntegrationDetails.tsx | 2 + apps/web/src/lib/integrations/oauth/common.ts | 15 + .../oauth/platforms/gitlab-callback.ts | 35 +- .../oauth/platforms/gitlab-connect.ts | 60 ++- .../security-agent-role-matrix.test.ts | 401 ++++++++++++++++++ 5 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/routers/organizations/security-agent-role-matrix.test.ts 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/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/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); + }); +}); From 727190de77db18e477e2d8ac498d4e08b4931885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 04:26:32 +0200 Subject: [PATCH 06/46] feat(user): expose notification producer capabilities Extend the notification preferences getter with a capabilities map for the seven category keys, computing availability from organization membership, enabled Security config, and KiloClaw instances. --- apps/web/src/routers/user-router.test.ts | 161 +++++++++++++++++++++++ apps/web/src/routers/user-router.ts | 87 +++++++++++- 2 files changed, 247 insertions(+), 1 deletion(-) 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, }; }), From dcfe2081b52de3a31ca81e9036b67ec40d279da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:15:47 +0200 Subject: [PATCH 07/46] feat(mobile): add bounded batch command status observer --- ...e-security-agent-commands.mounted.test.tsx | 259 +++++++++++++++ .../hooks/use-security-agent-commands.test.ts | 305 ++++++++++++++++++ .../lib/hooks/use-security-agent-commands.ts | 125 +++++-- apps/mobile/src/lib/security-agent.ts | 97 ++++++ 4 files changed, 767 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-security-agent-commands.mounted.test.tsx create mode 100644 apps/mobile/src/lib/hooks/use-security-agent-commands.test.ts 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..a34907915b 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,6 +53,31 @@ 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 @@ -129,15 +171,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 +211,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 +230,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 +270,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 +321,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/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 }; +} From 346dd226f5f27c18b03d522db6bae70455092fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:18:11 +0200 Subject: [PATCH 08/46] feat(mobile): render patch parts with file-list summary --- .../agents/message-visibility.test.ts | 19 ++ .../components/agents/message-visibility.ts | 3 +- .../components/agents/part-renderer.test.ts | 168 +++++++++++++++++- .../src/components/agents/part-renderer.tsx | 26 ++- .../src/components/agents/part-types.test.ts | 40 ++++- .../src/components/agents/part-types.ts | 5 + 6 files changed, 256 insertions(+), 5 deletions(-) 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 2a184f6545..2467230bcc 100644 --- a/apps/mobile/src/components/agents/part-renderer.test.ts +++ b/apps/mobile/src/components/agents/part-renderer.test.ts @@ -1,9 +1,18 @@ -import { type ReasoningPart, type TextPart } 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 { PartRenderer } from './part-renderer'; import { ReasoningPartRenderer } from './reasoning-part-renderer'; import { TextPartRenderer } from './text-part-renderer'; +import { PatchToolCardBody } from './tool-cards/patch-tool-card'; vi.mock('./child-session-section', () => ({})); vi.mock('./compaction-separator', () => ({ @@ -24,6 +33,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 { @@ -51,6 +75,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; +} + describe('PartRenderer', () => { it('does not mount a completed empty reasoning part', () => { const part = makeReasoningPart('', true); @@ -114,3 +238,45 @@ describe('PartRenderer', () => { expect(textElement.props).toMatchObject({ text: 'Hello world' }); }); }); + +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 0ef8b26878..801a113af4 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 { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; @@ -8,6 +11,7 @@ import { isCompactionPart, isFilePart, isPartStreaming, + isPatchPart, isReasoningPart, isTextPart, isToolPart, @@ -76,6 +80,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'; } From 46bb5437aa4a1b176e403ed8807c9dc72bac1b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:30:00 +0200 Subject: [PATCH 09/46] feat(mobile): add native security audit report screen --- .../security-agent/[scope]/audit-report.tsx | 8 + .../security-agent/audit-report-button.tsx | 14 +- .../audit-report-screen.mounted.test.tsx | 312 ++++++++++++++++++ .../security-agent/audit-report-screen.tsx | 206 ++++++++++++ 4 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/audit-report.tsx create mode 100644 apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/security-agent/audit-report-screen.tsx 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/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..cbae31bc72 --- /dev/null +++ b/apps/mobile/src/components/security-agent/audit-report-screen.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); 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('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..3d2906b774 --- /dev/null +++ b/apps/mobile/src/components/security-agent/audit-report-screen.tsx @@ -0,0 +1,206 @@ +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 = 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' && ( + + )} + + ); +} From 2deae16bcbdaa32638af0a21896c12c597b96660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:32:23 +0200 Subject: [PATCH 10/46] feat(mobile): persist attention items in encrypted KV --- apps/mobile/src/lib/session-attention.test.ts | 337 +++++++++++++++++- apps/mobile/src/lib/session-attention.ts | 306 ++++++++++++++-- apps/mobile/src/lib/storage-keys.ts | 6 + 3 files changed, 617 insertions(+), 32 deletions(-) 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, From f43674e79d983201b685e558672bbeb07e2059a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:37:31 +0200 Subject: [PATCH 11/46] feat(mobile): consume security lifecycle pushes with invalidation --- apps/mobile/src/app/(app)/_layout.tsx | 2 + .../lib/hooks/use-security-agent-commands.ts | 5 +- ...se-security-lifecycle-invalidation.test.ts | 344 ++++++++++++++++++ .../use-security-lifecycle-invalidation.ts | 121 ++++++ apps/mobile/src/lib/notification-path.test.ts | 46 +++ apps/mobile/src/lib/notification-path.ts | 6 +- 6 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.test.ts create mode 100644 apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.ts 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/lib/hooks/use-security-agent-commands.ts b/apps/mobile/src/lib/hooks/use-security-agent-commands.ts index a34907915b..f8dc24279f 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-commands.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-commands.ts @@ -81,8 +81,9 @@ export function invalidateSecurityAgentCommandObserver( // 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[] 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..cf1d66a5ed --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.test.ts @@ -0,0 +1,344 @@ +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[] }; + getCommandStatuses: { queryKey: () => string[] }; + listActiveCommands: { queryKey: () => string[] }; + }; + organizations: { + securityAgent: { + listFindings: { queryKey: OrgKey }; + getFinding: { 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'] }, + getCommandStatuses: { queryKey: () => ['securityAgent', 'getCommandStatuses'] }, + listActiveCommands: { queryKey: () => ['securityAgent', 'listActiveCommands'] }, + }, + organizations: { + securityAgent: { + listFindings: { queryKey: orgKey('listFindings') }, + getFinding: { queryKey: orgKey('getFinding') }, + 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'], + }, + { + scope: 'org_1', + findingsKey: ['organizations', 'securityAgent', 'listFindings', { organizationId: 'org_1' }], + findingKey: ['organizations', 'securityAgent', 'getFinding', { organizationId: 'org_1' }], + }, + ])( + 'invalidates the $scope findings, finding-details, and command-status queries', + ({ scope, findingsKey, findingKey }) => { + 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(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(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'getFinding'], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'getCommandStatuses'], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['securityAgent', 'listActiveCommands'], + }); + + expect(mocks.reconcileFirstPage).toHaveBeenCalledWith(queryClient, [ + 'organizations', + 'securityAgent', + 'listFindings', + ]); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['organizations', 'securityAgent', 'getFinding'], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['organizations', 'securityAgent', 'getCommandStatuses'], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['organizations', 'securityAgent', 'listActiveCommands'], + }); + }); +}); + +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..6e2cd68978 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-lifecycle-invalidation.ts @@ -0,0 +1,121 @@ +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']); + 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.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.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`; } From a8baf205cab69edd18e050034c25b9b11fe8121e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:37:31 +0200 Subject: [PATCH 12/46] feat(mobile): drive notification controls from producer capabilities --- .../notifications-screen.mounted.test.tsx | 133 ++++++++++++++++++ .../src/components/notifications-screen.tsx | 27 +++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/notifications-screen.mounted.test.tsx b/apps/mobile/src/components/notifications-screen.mounted.test.tsx index 64611ad6ce..793a437ae0 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,116 @@ 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(); + + await waitFor(() => switchesByLabel(renderer.root, 'Balance alerts').length === 1); + + 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 => { From feb6e8629e616c862c750972cde06ca416697b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:37:36 +0200 Subject: [PATCH 13/46] feat(security-agent): add auto-remediation approval gate with trust controls --- ...utomation-settings-screen.mounted.test.tsx | 181 ++++++++++++++++++ .../automation-settings-screen.tsx | 31 ++- .../security-agent/SecurityAgentContext.tsx | 6 + .../security-agent/SecurityConfigForm.test.ts | 52 +++++ .../security-agent/SecurityConfigForm.tsx | 88 ++++++--- .../security-agent/SecurityConfigPage.tsx | 1 + .../security-agent/security-config-types.ts | 108 +++++++++++ .../lib/security-agent/core/constants.test.ts | 11 ++ .../src/lib/security-agent/core/constants.ts | 1 + .../src/lib/security-agent/core/schemas.ts | 1 + apps/web/src/lib/security-agent/core/types.ts | 1 + .../security-agent/db/security-config.test.ts | 38 +++- .../lib/security-agent/db/security-config.ts | 5 +- .../security-agent/db/security-remediation.ts | 1 + .../router/shared-handlers.test.ts | 53 +++++ .../security-agent/router/shared-handlers.ts | 3 + .../src/security-remediation-policy.test.ts | 53 +++++ .../src/security-remediation-policy.ts | 3 + .../src/remediation.test.ts | 101 ++++++++++ .../security-auto-analysis/src/types.test.ts | 4 + services/security-auto-analysis/src/types.ts | 2 + 21 files changed, 712 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/components/security-agent/automation-settings-screen.mounted.test.tsx create mode 100644 apps/web/src/components/security-agent/SecurityConfigForm.test.ts 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..d35b1f402c --- /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 (() => void) | 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: () => {}, + useSettingsBackGuard: () => ({ onBack: () => {}, skipNextGuardRef: { current: false } }), +})); +vi.mock('@/components/security-agent/settings-pill-group', () => ({ + PillGroup: () => null, +})); +vi.mock('@/components/security-agent/settings-save-button', () => ({ + SettingsSaveButton: (props: { onSave: () => void }) => { + 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..ea01b2ca46 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,23 @@ 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 +231,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 +249,13 @@ export function AutomationSettingsScreen({ scope }: Readonly<{ scope: string }>) disabled={!canManage} onValueChange={setAutoRemediationIncludeExisting} /> + )} diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index ace67a966d..5a77f80c69 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -68,6 +68,7 @@ type SecurityAgentContextValue = { autoRemediationEnabled: boolean; autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoRemediationIncludeExisting: boolean; + autoRemediationRequireApproval: boolean; autoRemediationEnabledAt: string | null; remediationModelSlug?: string; slaNotificationsEnabled: boolean; @@ -121,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'; @@ -1191,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'; @@ -1228,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, @@ -1257,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, @@ -1424,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-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/security-agent/core/constants.test.ts b/apps/web/src/lib/security-agent/core/constants.test.ts index fe1412fb2e..077059f0b6 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 required', () => { + expect(parseSecurityAgentConfig({}).auto_remediation_require_approval).toBe(true); + }); + 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..4680e3104e 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: true, 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 9f46a66ee3..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(), diff --git a/apps/web/src/lib/security-agent/core/types.ts b/apps/web/src/lib/security-agent/core/types.ts index 20dcc461e5..be4697c0d7 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(true), 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-config.test.ts b/apps/web/src/lib/security-agent/db/security-config.test.ts index 3ca435fd49..4d3156e42d 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,39 @@ 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 07b9a96490..bca00dbb41 100644 --- a/apps/web/src/lib/security-agent/db/security-config.ts +++ b/apps/web/src/lib/security-agent/db/security-config.ts @@ -283,7 +283,10 @@ 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' satisfies SecurityCommandType, origin: 'settings_include_existing', 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 b459025460..91db017879 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 @@ -385,6 +385,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', () => { @@ -501,6 +539,21 @@ 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 }), + }) + ); + }); }); describe('autoDismissEligible', () => { 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 0de2ca38ef..02f016580b 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -701,6 +701,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, @@ -753,6 +754,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, @@ -912,6 +914,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, 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..ff9ae45b95 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; }; @@ -99,6 +100,7 @@ export type SecurityRemediationCapabilityReason = 'eligible' | SecurityRemediati export const SECURITY_REMEDIATION_ADMISSION_REJECTION_REASONS = [ ...SECURITY_REMEDIATION_REJECTION_REASONS, 'finding_not_found', + 'approval_required', ] as const; export type SecurityRemediationAdmissionRejectionReason = @@ -375,6 +377,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/security-auto-analysis/src/remediation.test.ts b/services/security-auto-analysis/src/remediation.test.ts index 03b2300560..0e552d7c55 100644 --- a/services/security-auto-analysis/src/remediation.test.ts +++ b/services/security-auto-analysis/src/remediation.test.ts @@ -7,6 +7,7 @@ import { buildRemediationPrepareSessionBody, buildRemediationPrompt, } from './remediation.js'; +import { DEFAULT_SECURITY_AGENT_CONFIG } from './types.js'; vi.mock('./db/queries.js', async importOriginal => ({ ...(await importOriginal()), @@ -41,6 +42,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({ diff --git a/services/security-auto-analysis/src/types.test.ts b/services/security-auto-analysis/src/types.test.ts index 0e7a6c6dff..ef20966a8c 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 required', () => { + expect(DEFAULT_SECURITY_AGENT_CONFIG.auto_remediation_require_approval).toBe(true); + }); }); describe('resolveSecurityAgentModels', () => { diff --git a/services/security-auto-analysis/src/types.ts b/services/security-auto-analysis/src/types.ts index 11dbb5626b..98fe4e10bb 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(true), 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: true, auto_remediation_enabled_at: null, remediation_model_slug: 'anthropic/claude-opus-4.6', }; From c2d70ec1e36362e824dafddbce2008eb45216df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:37:37 +0200 Subject: [PATCH 14/46] feat(organizations): narrow member-visible DTOs by role --- .../lib/organizations/organization-types.ts | 25 +++ .../src/routers/code-reviews-router.test.ts | 91 +++++++++ .../code-reviews/code-reviews-router.ts | 24 ++- .../organization-funds-router.test.ts | 28 ++- .../organizations/organization-router.test.ts | 180 +++++++++++++++++- .../organizations/organization-router.ts | 43 ++++- 6 files changed, 386 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/organizations/organization-types.ts b/apps/web/src/lib/organizations/organization-types.ts index de271b4c75..1a15e48a6c 100644 --- a/apps/web/src/lib/organizations/organization-types.ts +++ b/apps/web/src/lib/organizations/organization-types.ts @@ -180,6 +180,31 @@ export const PublicOrganizationMemberSchema = z.discriminatedUnion('status', [ export const PublicOrganizationMembersSchema = z.array(PublicOrganizationMemberSchema); +// Member-visible variants returned by `organizations.withMembers` for `member` +// callers. The response type stays `OrganizationWithMembers` (the admin +// superset) because member-facing consumers (OrganizationMembersCard, the +// mobile members screen) read the remaining fields and cannot migrate in this +// PR. The key-set test in organization-router.test.ts pins the exact member +// shape. +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, +}); + +export const MemberOrganizationMemberSchema = z.discriminatedUnion('status', [ + MemberActiveOrganizationMemberSchema, + MemberInvitedOrganizationMemberSchema, +]); + export const OrganizationWithMembersSchema = OrganizationSchema.extend({ members: z.array(OrganizationMemberSchema), }); diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts index a800288e31..210639c9a5 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,96 @@ 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('personalReviewAgent.createManualReviewJob', () => { let testUser: User; let fetchSpy: jest.SpiedFunction | null = null; 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..36f7b781d6 100644 --- a/apps/web/src/routers/code-reviews/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews/code-reviews-router.ts @@ -248,8 +248,30 @@ export const codeReviewRouter = createTRPCRouter({ }), ]); + // 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: ListCodeReviewsResponse = { - reviews, + reviews: visibleReviews, total, hasMore: offset + reviews.length < total, }; 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..5c51c735c2 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -122,6 +122,20 @@ function getDateThreshold(period: string): Date | null { } } +/** + * Returns a shallow copy of `obj` with `keys` deleted. The return type stays + * `T` (the admin superset) because the narrowed member shape is pinned by the + * key-set test rather than a separate response type; a union response type + * would break member-facing consumers outside this slice. + */ +function omitKeys(obj: T, keys: Array): T { + const copy: Record = { ...obj } as Record; + for (const key of keys) { + delete copy[key as string]; + } + return copy as T; +} + export const organizationsRouter = createTRPCRouter({ members: organizationsMembersRouter, subscription: organizationsSubscriptionRouter, @@ -402,10 +416,35 @@ 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 isMember = callerRole === 'member'; + const organizationPayload = isMember + ? omitKeys(organization, ['stripe_customer_id']) + : organization; + const membersPayload = isMember + ? membersWithChildOrganizations.map(member => + member.status === 'active' + ? omitKeys(member, ['currentDailyUsageUsd']) + : omitKeys(member, ['inviteToken', 'inviteUrl', 'currentDailyUsageUsd']) + ) + : membersWithChildOrganizations; + return { - ...organization, + ...organizationPayload, callerRole, - members: membersWithChildOrganizations, + members: membersPayload, childOrganizations, effectiveSsoPolicy: ssoPolicy.status === 'required' From e521079cfced538a15bc299e9b8643fdd4fbc81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:55:39 +0200 Subject: [PATCH 15/46] feat(code-reviews): add paginated review memory with native screen --- .../code-reviewer/[scope]/review-memory.tsx | 8 + .../code-reviewer/platform-overview-rows.ts | 17 + .../platform-overview-screen.tsx | 8 + .../review-memory-screen.mounted.test.tsx | 378 ++++++++++++++++++ .../code-reviewer/review-memory-screen.tsx | 203 ++++++++++ .../code-reviews/ReviewMemoryPanel.tsx | 2 +- .../src/lib/code-reviews/review-memory/db.ts | 68 +++- .../code-reviews/review-memory-router.test.ts | 145 +++++++ .../code-reviews/review-memory-router.ts | 2 + packages/trpc/src/mobile.ts | 2 + 10 files changed, 827 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/review-memory.tsx create mode 100644 apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/code-reviewer/review-memory-screen.tsx create mode 100644 apps/web/src/routers/code-reviews/review-memory-router.test.ts 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/components/code-reviewer/platform-overview-rows.ts b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts index dedd494dbb..fd25b1da22 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, @@ -31,12 +32,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,6 +93,20 @@ 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). + ...(onOpenReviewMemory + ? [ + { + field: 'review-memory', + icon: Brain, + title: 'Review memory', + subtitle: 'Proposed REVIEW.md guidance', + onPress: onOpenReviewMemory, + }, + ] + : []), ]; } 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-memory-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx new file mode 100644 index 0000000000..c04c1c0561 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx @@ -0,0 +1,378 @@ +/* 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, 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 } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ReviewMemoryScreen } from './review-memory-screen'; + +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'], + }, + listProposals: { + infiniteQueryOptions: () => ({}), + }, + setEnabled: { + mutationOptions: () => ({}), + }, + }, + }), +})); + +vi.mock('@/lib/code-reviewer-config', () => ({ + PERSONAL_SCOPE: 'personal', +})); + +vi.mock('@/lib/hooks/use-code-reviewer', () => ({ + useReviewerPermission: () => permission, +})); + +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 }) => unknown; + ListEmptyComponent?: unknown; + ListFooterComponent?: unknown; + onEndReached?: () => void; + }) => { + 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 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 []; +} + +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 []; +} + +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({ platform: 'github', enabled: 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(); + }); +}); + +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.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx new file mode 100644 index 0000000000..fbb29bfe5a --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx @@ -0,0 +1,203 @@ +import { FlashList } from '@shopify/flash-list'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } 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 { announcingToast } from '@/lib/a11y/announcing-toast'; +import { PERSONAL_SCOPE } from '@/lib/code-reviewer-config'; +import { useReviewerPermission } 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 queryClient = useQueryClient(); + 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.listProposals.infiniteQueryOptions( + { ...ownerInput, limit: PAGE_SIZE }, + { + enabled, + getNextPageParam: lastPage => lastPage.nextCursor ?? undefined, + } + ) + ); + + const setEnabled = useMutation( + trpc.reviewMemory.setEnabled.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.reviewMemory.getDashboardSummary.queryKey(ownerInput), + }); + }, + onError: error => { + announcingToast.error(error.message); + }, + }) + ); + + const proposals = useMemo( + () => (proposalsQuery.data?.pages ?? []).flatMap(page => page.proposals), + [proposalsQuery.data?.pages] + ); + + const canEdit = 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. + + {canEdit ? ( + + ) : ( + + 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/web/src/components/code-reviews/ReviewMemoryPanel.tsx b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx index fd2c0cc191..cecc737696 100644 --- a/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx +++ b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx @@ -63,7 +63,7 @@ export function ReviewMemoryPanel({ organizationId, platform }: ReviewMemoryPane const summary = summaryQuery.data; const memoryEnabled = summary?.enabled ?? false; const repositories = summary?.repositories ?? []; - const proposals = proposalsQuery.data ?? []; + const proposals = proposalsQuery.data?.proposals ?? []; const selectedProposal = proposals.find(proposal => proposal.id === selectedProposalId) ?? null; const canEditSelectedProposal = selectedProposal ? selectedProposal.status === 'open' || 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..6f9e65d725 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,15 +223,47 @@ 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 `listProposals`. 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 }; +} + export async function listProposals(input: { owner: ReviewMemoryOwner; platform: ReviewMemoryPlatform; repoFullName?: string; statuses?: ReviewMemoryProposalStatus[]; limit?: number; + cursor?: string; database?: ReviewMemoryDatabase; -}): Promise { +}): 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 +274,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/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..becd3d3f8b --- /dev/null +++ b/apps/web/src/routers/code-reviews/review-memory-router.test.ts @@ -0,0 +1,145 @@ +/* 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 `listProposals`: 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 listProposals 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.listProposals({ 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.listProposals({ + 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.listProposals({ + 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.listProposals({ platform: 'github', limit: 2 }); + const page2 = await caller.reviewMemory.listProposals({ + 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.listProposals({ platform: 'github', limit: 1 }); + expect(page1.proposals.map(proposal => proposal.id)).toEqual([a.id]); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = await caller.reviewMemory.listProposals({ + 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.listProposals({ platform: 'github', cursor: 'not-a-cursor' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + await expect( + caller.reviewMemory.listProposals({ + platform: 'github', + cursor: '2026-06-05T00:00:00.000Z|not-a-uuid', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); +}); 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..6eb4b3dd82 100644 --- a/apps/web/src/routers/code-reviews/review-memory-router.ts +++ b/apps/web/src/routers/code-reviews/review-memory-router.ts @@ -71,6 +71,7 @@ export const reviewMemoryRouter = createTRPCRouter({ 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 }) => { @@ -81,6 +82,7 @@ export const reviewMemoryRouter = createTRPCRouter({ repoFullName: input.repoFullName, statuses: input.statuses, limit: input.limit, + cursor: input.cursor, }); }), 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, From d91073a173158013893806e81e88ea607df2800c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:58:09 +0200 Subject: [PATCH 16/46] fix(security-agent): complete approval_required reason surface --- packages/app-shared/src/security-agent/presentation.ts | 2 ++ packages/worker-utils/src/security-remediation-policy.ts | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/worker-utils/src/security-remediation-policy.ts b/packages/worker-utils/src/security-remediation-policy.ts index ff9ae45b95..462641d4be 100644 --- a/packages/worker-utils/src/security-remediation-policy.ts +++ b/packages/worker-utils/src/security-remediation-policy.ts @@ -95,7 +95,6 @@ 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, @@ -106,6 +105,13 @@ export const SECURITY_REMEDIATION_ADMISSION_REJECTION_REASONS = [ 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; From 8575113d53c1fd170dfdb3abfc3ca1e4bf776d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:58:10 +0200 Subject: [PATCH 17/46] fix(mobile): type review-memory FlashList test mock --- .../code-reviewer/review-memory-screen.mounted.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index c04c1c0561..b73ab017d2 100644 --- 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 @@ -6,7 +6,7 @@ // paginated happy list. The query layer is mocked so each state is driven // directly through the screen JSX. -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -101,9 +101,9 @@ vi.mock('react-native', () => ({ vi.mock('@shopify/flash-list', () => ({ FlashList: (props: { data?: unknown[]; - renderItem?: (info: { item: unknown; index: number }) => unknown; - ListEmptyComponent?: unknown; - ListFooterComponent?: unknown; + renderItem?: (info: { item: unknown; index: number }) => ReactNode; + ListEmptyComponent?: ReactNode; + ListFooterComponent?: ReactNode; onEndReached?: () => void; }) => { flashList.onEndReached = props.onEndReached ?? null; From 934836caffab651c26144b7b73e0ecc2da138829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 06:17:03 +0200 Subject: [PATCH 18/46] fix(code-reviews): keep array listProposals and add paginated page --- .../code-reviews/ReviewMemoryPanel.tsx | 2 +- .../src/lib/code-reviews/review-memory/db.ts | 18 +++++++++- .../code-reviews/review-memory-router.test.ts | 36 +++++++++++++------ .../code-reviews/review-memory-router.ts | 24 ++++++++++++- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx index cecc737696..fd2c0cc191 100644 --- a/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx +++ b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx @@ -63,7 +63,7 @@ export function ReviewMemoryPanel({ organizationId, platform }: ReviewMemoryPane const summary = summaryQuery.data; const memoryEnabled = summary?.enabled ?? false; const repositories = summary?.repositories ?? []; - const proposals = proposalsQuery.data?.proposals ?? []; + const proposals = proposalsQuery.data ?? []; const selectedProposal = proposals.find(proposal => proposal.id === selectedProposalId) ?? null; const canEditSelectedProposal = selectedProposal ? selectedProposal.status === 'open' || 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 6f9e65d725..1e789546b6 100644 --- a/apps/web/src/lib/code-reviews/review-memory/db.ts +++ b/apps/web/src/lib/code-reviews/review-memory/db.ts @@ -231,7 +231,7 @@ export type ReviewMemoryProposalPage = { 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 `listProposals`. The list orders by +// 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. @@ -253,7 +253,23 @@ function decodeProposalCursor(cursor: string): { updatedAt: string; id: string } 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; + repoFullName?: string; + statuses?: ReviewMemoryProposalStatus[]; + limit?: number; + database?: ReviewMemoryDatabase; +}): Promise { + const page = await listProposalsPage(input); + return page.proposals; +} + +export async function listProposalsPage(input: { owner: ReviewMemoryOwner; platform: ReviewMemoryPlatform; repoFullName?: string; 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 index becd3d3f8b..a3bc9cdb60 100644 --- a/apps/web/src/routers/code-reviews/review-memory-router.test.ts +++ b/apps/web/src/routers/code-reviews/review-memory-router.test.ts @@ -5,11 +5,11 @@ import { code_review_memory_proposals, kilocode_users } from '@kilocode/db/schem import { createCallerForUser } from '@/routers/test-utils'; -// Keyset pagination contract for `listProposals`: the cursor encodes the last +// 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 listProposals pagination', () => { +describe('review memory listProposalsPage pagination', () => { let userId: string; beforeEach(async () => { @@ -52,14 +52,14 @@ describe('review memory listProposals pagination', () => { const caller = await createCallerForUser(userId); - const page1 = await caller.reviewMemory.listProposals({ platform: 'github', limit: 2 }); + 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.listProposals({ + const page2 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2, cursor: page1.nextCursor!, @@ -70,7 +70,7 @@ describe('review memory listProposals pagination', () => { ]); expect(page2.nextCursor).not.toBeNull(); - const page3 = await caller.reviewMemory.listProposals({ + const page3 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2, cursor: page2.nextCursor!, @@ -87,8 +87,8 @@ describe('review memory listProposals pagination', () => { const caller = await createCallerForUser(userId); - const page1 = await caller.reviewMemory.listProposals({ platform: 'github', limit: 2 }); - const page2 = await caller.reviewMemory.listProposals({ + const page1 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2 }); + const page2 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 2, cursor: page1.nextCursor!, @@ -115,11 +115,11 @@ describe('review memory listProposals pagination', () => { const caller = await createCallerForUser(userId); - const page1 = await caller.reviewMemory.listProposals({ platform: 'github', limit: 1 }); + 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.listProposals({ + const page2 = await caller.reviewMemory.listProposalsPage({ platform: 'github', limit: 1, cursor: page1.nextCursor!, @@ -132,14 +132,28 @@ describe('review memory listProposals pagination', () => { const caller = await createCallerForUser(userId); await expect( - caller.reviewMemory.listProposals({ platform: 'github', cursor: 'not-a-cursor' }) + caller.reviewMemory.listProposalsPage({ platform: 'github', cursor: 'not-a-cursor' }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); await expect( - caller.reviewMemory.listProposals({ + 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 6eb4b3dd82..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, @@ -71,7 +72,6 @@ export const reviewMemoryRouter = createTRPCRouter({ 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 }) => { @@ -82,6 +82,28 @@ export const reviewMemoryRouter = createTRPCRouter({ repoFullName: input.repoFullName, statuses: input.statuses, limit: input.limit, + }); + }), + + // 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, }); }), From d0cfa2f9c5679319970374a7defb30204f285b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 06:17:04 +0200 Subject: [PATCH 19/46] fix(mobile): complete review-memory review fixes --- .../code-reviewer/platform-overview-rows.ts | 15 ++++-- .../review-memory-screen.mounted.test.tsx | 47 ++++--------------- .../review-memory-screen.test-helpers.ts | 34 ++++++++++++++ .../code-reviewer/review-memory-screen.tsx | 26 ++++------ .../mobile/src/lib/hooks/use-code-reviewer.ts | 28 +++++++++++ 5 files changed, 89 insertions(+), 61 deletions(-) create mode 100644 apps/mobile/src/components/code-reviewer/review-memory-screen.test-helpers.ts 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 fd25b1da22..03ad68e257 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts +++ b/apps/mobile/src/components/code-reviewer/platform-overview-rows.ts @@ -19,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; }; /** @@ -95,7 +97,8 @@ export function buildOverviewRows({ }, // 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). + // 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 ? [ { @@ -104,21 +107,23 @@ export function buildOverviewRows({ 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/review-memory-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx index b73ab017d2..abe3a19be6 100644 --- 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 @@ -6,11 +6,12 @@ // paginated happy list. The query layer is mocked so each state is driven // directly through the screen JSX. -import { createElement, type ReactNode } from 'react'; +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, @@ -67,7 +68,7 @@ vi.mock('@/lib/trpc', () => ({ queryOptions: () => ({}), queryKey: () => ['summary'], }, - listProposals: { + listProposalsPage: { infiniteQueryOptions: () => ({}), }, setEnabled: { @@ -83,6 +84,7 @@ vi.mock('@/lib/code-reviewer-config', () => ({ vi.mock('@/lib/hooks/use-code-reviewer', () => ({ useReviewerPermission: () => permission, + useSetReviewMemoryEnabled: () => setEnabled, })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ @@ -101,11 +103,11 @@ vi.mock('react-native', () => ({ vi.mock('@shopify/flash-list', () => ({ FlashList: (props: { data?: unknown[]; - renderItem?: (info: { item: unknown; index: number }) => ReactNode; - ListEmptyComponent?: ReactNode; - ListFooterComponent?: ReactNode; + 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) { @@ -146,37 +148,6 @@ vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/icons', () => ({ Brain: 'Brain' })); -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 []; -} - -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 []; -} - function renderScreen(): TestRenderer.ReactTestRenderer { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { @@ -262,7 +233,7 @@ describe('ReviewMemoryScreen feature disabled', () => { enableButton.onPress?.(); }); } - expect(setEnabled.mutate).toHaveBeenCalledWith({ platform: 'github', enabled: true }); + expect(setEnabled.mutate).toHaveBeenCalledWith(true); }); it('shows static off-state text with no CTA for a plain member', () => { 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 index fbb29bfe5a..d190f221bb 100644 --- a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx @@ -1,5 +1,5 @@ import { FlashList } from '@shopify/flash-list'; -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { ActivityIndicator, View } from 'react-native'; @@ -10,9 +10,11 @@ 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 { announcingToast } from '@/lib/a11y/announcing-toast'; import { PERSONAL_SCOPE } from '@/lib/code-reviewer-config'; -import { useReviewerPermission } from '@/lib/hooks/use-code-reviewer'; +import { + useReviewerPermission, + useSetReviewMemoryEnabled, +} from '@/lib/hooks/use-code-reviewer'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useTRPC } from '@/lib/trpc'; @@ -28,7 +30,6 @@ function reviewMemoryOwnerInput(scope: string) { export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const colors = useThemeColors(); const ownerInput = reviewMemoryOwnerInput(scope); const permission = useReviewerPermission(scope); @@ -37,7 +38,7 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { const enabled = summaryQuery.data?.enabled === true; const proposalsQuery = useInfiniteQuery( - trpc.reviewMemory.listProposals.infiniteQueryOptions( + trpc.reviewMemory.listProposalsPage.infiniteQueryOptions( { ...ownerInput, limit: PAGE_SIZE }, { enabled, @@ -46,18 +47,7 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { ) ); - const setEnabled = useMutation( - trpc.reviewMemory.setEnabled.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: trpc.reviewMemory.getDashboardSummary.queryKey(ownerInput), - }); - }, - onError: error => { - announcingToast.error(error.message); - }, - }) - ); + const setEnabled = useSetReviewMemoryEnabled(scope); const proposals = useMemo( () => (proposalsQuery.data?.pages ?? []).flatMap(page => page.proposals), @@ -147,7 +137,7 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { {canEdit ? ( ) : 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 ? ( ({ })); 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/web/src/lib/security-agent/router/shared-handlers.test.ts b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts index 0fdb6d5b08..e59320ee26 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 @@ -8,7 +8,13 @@ import type * as manualRemediationClientModule from '../services/manual-remediat import { randomUUID } from 'crypto'; 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'; @@ -658,6 +664,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({ 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 e330220cb5..0b522ab9da 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -68,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, @@ -718,6 +722,51 @@ function toFindingListItem( 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 // --------------------------------------------------------------------------- @@ -2011,11 +2060,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, @@ -2042,6 +2093,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps remediationSummary: decoratedFinding.remediationSummary ?? null, remediationCapability: decoratedFinding.remediationCapability, remediationAttempts, + remediationTimeline, }; }, }, From ce5b1959c1d760fe91eaec7b3436664e84ac23fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 13:08:48 +0200 Subject: [PATCH 29/46] feat(mobile): route remediation PR buttons into native PR review --- ...finding-remediation-panel.mounted.test.tsx | 142 +++++++++++++++++- .../finding-remediation-panel.tsx | 23 ++- 2 files changed, 160 insertions(+), 5 deletions(-) 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 index 7005679316..d198bbdc45 100644 --- 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 @@ -13,18 +13,32 @@ 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() }, - Linking: { openURL: 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: () => null, + CollapsibleSection: (props: { children?: unknown }) => props.children ?? null, })); vi.mock('@/components/security-agent/finding-status-badge', () => ({ FindingStatusBadge: () => null, @@ -114,6 +128,17 @@ function renderPanel(analysis: SecurityAnalysis): R { 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 = []; @@ -163,3 +188,116 @@ describe('FindingRemediationPanel remediation timeline', () => { 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 c479b166cc..8757648f1d 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'; @@ -63,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 ( @@ -203,7 +220,7 @@ export function FindingRemediationPanel({ - ) : ( - - Only organization owners and billing managers can enable review memory. - )} )}