diff --git a/apps/gateway/src/presenters/runPresenter.ts b/apps/gateway/src/presenters/runPresenter.ts index 36446721dc..f1714521db 100644 --- a/apps/gateway/src/presenters/runPresenter.ts +++ b/apps/gateway/src/presenters/runPresenter.ts @@ -1,19 +1,17 @@ import { captureException } from '$/common/tracer' import { AssertedStreamType, - ChainStepObjectResponse, - ChainStepTextResponse, + ChainStepResponse, RunSyncAPIResponse, + StreamType, } from '@latitude-data/constants' import { LatitudeError } from '@latitude-data/constants/errors' import { Result, TypedResult } from '@latitude-data/core/lib/Result' import { ProviderApiKey } from '@latitude-data/core/schema/models/types/ProviderApiKey' import { estimateCost } from '@latitude-data/core/services/ai/estimateCost/index' -type DocumentResponse = ChainStepObjectResponse | ChainStepTextResponse - export function v2RunPresenter( - response: DocumentResponse, + response: ChainStepResponse, ): TypedResult< Omit, 'toolRequests'>, LatitudeError @@ -51,7 +49,7 @@ export function runPresenter({ response, provider, }: { - response: DocumentResponse + response: ChainStepResponse provider: ProviderApiKey }): TypedResult, LatitudeError> { const conversation = response.providerLog?.messages diff --git a/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.test.ts b/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.test.ts index c3da696cfa..93f57f7730 100644 --- a/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.test.ts +++ b/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.test.ts @@ -13,6 +13,7 @@ import { createProject, createTelemetryTrace, helpers, + updateDocumentVersion, } from '@latitude-data/core/factories' import { Result } from '@latitude-data/core/lib/Result' import { mergeCommit } from '@latitude-data/core/services/commits/merge' @@ -28,6 +29,7 @@ import { Commit } from '@latitude-data/core/schema/models/types/Commit' import { Workspace } from '@latitude-data/core/schema/models/types/Workspace' import { Project } from '@latitude-data/core/schema/models/types/Project' import { ProviderApiKey } from '@latitude-data/core/schema/models/types/ProviderApiKey' +import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' import { generateUUIDIdentifier } from '@latitude-data/core/lib/generateUUID' import { estimateCost } from '@latitude-data/core/services/ai/estimateCost/index' import { Providers } from '@latitude-data/constants' @@ -40,6 +42,10 @@ const mocks = vi.hoisted(() => ({ captureExceptionMock: vi.fn(), enqueueRun: vi.fn(), isFeatureEnabledByName: vi.fn(), + findActiveForCommit: vi.fn(), + getCommitById: vi.fn(), + getHeadCommit: vi.fn(), + routeRequest: vi.fn(), queues: { defaultQueue: { jobs: { @@ -86,6 +92,35 @@ vi.mock( }), ) +vi.mock('@latitude-data/core/repositories/deploymentTestsRepository', () => ({ + DeploymentTestsRepository: vi.fn().mockImplementation(() => ({ + findActiveForCommit: mocks.findActiveForCommit, + })), +})) + +vi.mock('@latitude-data/core/repositories', async (importOriginal) => { + const actual: any = await importOriginal() + const OriginalCommitsRepository = actual.CommitsRepository + + const MockedClass = class MockedCommitsRepository extends OriginalCommitsRepository { + getHeadCommit(projectId: number) { + return mocks.getHeadCommit(projectId) + } + getCommitById(id: number) { + return mocks.getCommitById(id) + } + } + + return { + ...actual, + CommitsRepository: MockedClass, + } as any +}) + +vi.mock('@latitude-data/core/services/deploymentTests/routeRequest', () => ({ + routeRequest: mocks.routeRequest, +})) + let route: string let body: string let token: string @@ -94,6 +129,8 @@ let project: Project let workspace: Workspace let commit: Commit let provider: ProviderApiKey +let user: any +let document: DocumentVersion describe('POST /run', () => { describe('unauthorized', () => { @@ -1209,4 +1246,496 @@ describe('POST /run', () => { expect(mocks.enqueueRun).not.toHaveBeenCalled() }) }) + + describe('deployment test routing', () => { + beforeEach(async () => { + const { + workspace: wsp, + user: usr, + project: prj, + providers, + } = await createProject() + project = prj + workspace = wsp + user = usr + provider = providers[0]! + + // Reset and set default mock implementations BEFORE creating commits/documents + mocks.findActiveForCommit.mockReset() + mocks.findActiveForCommit.mockResolvedValue(undefined) + + const apikey = await unsafelyGetFirstApiKeyByWorkspaceId({ + workspaceId: workspace.id, + }).then((r) => r.unwrap()) + token = apikey?.token! + const path = 'path/to/document' + const { commit: cmt } = await createDraft({ + project, + user, + }) + const doc = await createDocumentVersion({ + workspace, + user, + commit: cmt, + path, + content: helpers.createPrompt({ provider: providers[0]! }), + }) + document = doc.documentVersion + + commit = await mergeCommit(cmt).then((r) => r.unwrap()) + + route = `/api/v3/projects/${project!.id}/versions/${commit!.uuid}/documents/run` + body = JSON.stringify({ + path: document.path, + parameters: {}, + }) + headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-Latitude-SDK-Version': '5.0.0', + } + + // Reset other mocks + mocks.runDocumentAtCommit.mockClear() + mocks.enqueueRun.mockClear() + mocks.getCommitById.mockClear() + mocks.getHeadCommit.mockClear() + mocks.routeRequest.mockClear() + + // Mock getHeadCommit to return the baseline commit + mocks.getHeadCommit.mockResolvedValue(commit) + + // Set default mocks + mocks.isFeatureEnabledByName.mockImplementation((_, featureName) => { + if (featureName === 'api-background-runs') { + return Promise.resolve(Result.ok(false)) + } + return Promise.resolve(Result.ok(false)) + }) + + mocks.findActiveForCommit.mockResolvedValue(undefined) + }) + + describe('with shadow test', () => { + it('should execute baseline run with shadow test context', async () => { + const shadowTest = { + id: 1, + uuid: 'test-uuid', + workspaceId: workspace.id, + projectId: project.id, + documentUuid: 'doc-uuid', + challengerCommitId: 999, + testType: 'shadow', + trafficPercentage: 100, + status: 'running', + } + + mocks.findActiveForCommit.mockResolvedValue(shadowTest) + + const documentLogUuid = generateUUIDIdentifier() + const usage = { + promptTokens: 4, + completionTokens: 6, + totalTokens: 10, + inputTokens: 4, + outputTokens: 6, + reasoningTokens: 0, + cachedInputTokens: 0, + } + + await createProviderLog({ + documentLogUuid, + workspace, + providerId: provider.id, + providerType: provider.provider, + model: MODEL, + messages: [ + { role: MessageRole.assistant, content: 'Hello', toolCalls: [] }, + ], + tokens: usage.totalTokens, + }) + + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + mocks.runDocumentAtCommit.mockReturnValue( + Promise.resolve( + Result.ok({ + stream, + lastResponse: Promise.resolve({ + streamType: 'text', + text: 'Hello', + usage, + documentLogUuid, + providerLog: { + providerId: provider.id, + model: MODEL, + messages: [ + { + role: MessageRole.assistant, + content: 'Hello', + toolCalls: [], + }, + ], + }, + }), + }), + ), + ) + + const res = await app.request(route, { + method: 'POST', + body, + headers, + }) + + expect(res.status).toBe(200) + expect(mocks.runDocumentAtCommit).toHaveBeenCalled() + // Should use original commit for baseline + expect(mocks.runDocumentAtCommit).toHaveBeenCalledWith( + expect.objectContaining({ + commit: expect.objectContaining({ id: commit.id }), + }), + ) + }) + }) + + describe('with A/B test', () => { + let challengerCommit: Commit + + beforeEach(async () => { + const { commit: newCommit } = await createDraft({ + project, + user, + }) + await updateDocumentVersion({ + document, + commit: newCommit, + content: helpers.createPrompt({ provider }), + }) + challengerCommit = await mergeCommit(newCommit).then((r) => r.unwrap()) + }) + + it('should route to baseline when routeRequest returns baseline', async () => { + const abTest = { + id: 1, + uuid: 'test-uuid', + workspaceId: workspace.id, + projectId: project.id, + documentUuid: 'doc-uuid', + challengerCommitId: challengerCommit.id, + testType: 'ab', + trafficPercentage: 50, + status: 'running', + } + + mocks.findActiveForCommit.mockResolvedValue(abTest) + mocks.getHeadCommit.mockResolvedValue(commit) + mocks.routeRequest.mockReturnValue('baseline') + mocks.getCommitById.mockResolvedValue(Result.ok(commit)) + + const documentLogUuid = generateUUIDIdentifier() + const usage = { + promptTokens: 4, + completionTokens: 6, + totalTokens: 10, + inputTokens: 4, + outputTokens: 6, + reasoningTokens: 0, + cachedInputTokens: 0, + } + + await createProviderLog({ + documentLogUuid, + workspace, + providerId: provider.id, + providerType: provider.provider, + model: MODEL, + messages: [ + { role: MessageRole.assistant, content: 'Hello', toolCalls: [] }, + ], + tokens: usage.totalTokens, + }) + + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + mocks.runDocumentAtCommit.mockReturnValue( + Promise.resolve( + Result.ok({ + stream, + lastResponse: Promise.resolve({ + streamType: 'text', + text: 'Hello', + usage, + documentLogUuid, + providerLog: { + providerId: provider.id, + model: MODEL, + messages: [ + { + role: MessageRole.assistant, + content: 'Hello', + toolCalls: [], + }, + ], + }, + }), + }), + ), + ) + + const res = await app.request(route, { + method: 'POST', + body: JSON.stringify({ + path: 'path/to/document', + parameters: {}, + customIdentifier: 'user-123', + }), + headers, + }) + + expect(res.status).toBe(200) + expect(mocks.findActiveForCommit).toHaveBeenCalled() + expect(mocks.routeRequest).toHaveBeenCalledWith(abTest, 'user-123') + // When routing to baseline, getHeadCommit should be called to get the head commit + expect(mocks.getHeadCommit).toHaveBeenCalledWith(project.id) + expect(mocks.runDocumentAtCommit).toHaveBeenCalledWith( + expect.objectContaining({ + source: LogSources.API, + commit: expect.objectContaining({ id: commit.id }), + }), + ) + }) + + it('should route to challenger when routeRequest returns challenger', async () => { + const abTest = { + id: 1, + uuid: 'test-uuid', + workspaceId: workspace.id, + projectId: project.id, + documentUuid: 'doc-uuid', + challengerCommitId: challengerCommit.id, + testType: 'ab', + trafficPercentage: 50, + status: 'running', + } + + mocks.findActiveForCommit.mockResolvedValue(abTest) + mocks.getHeadCommit.mockResolvedValue(commit) + mocks.routeRequest.mockReturnValue('challenger') + mocks.getCommitById.mockResolvedValue(Result.ok(challengerCommit)) + + const documentLogUuid = generateUUIDIdentifier() + const usage = { + promptTokens: 4, + completionTokens: 6, + totalTokens: 10, + inputTokens: 4, + outputTokens: 6, + reasoningTokens: 0, + cachedInputTokens: 0, + } + + await createProviderLog({ + documentLogUuid, + workspace, + providerId: provider.id, + providerType: provider.provider, + model: MODEL, + messages: [ + { role: MessageRole.assistant, content: 'Hello', toolCalls: [] }, + ], + tokens: usage.totalTokens, + }) + + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + mocks.runDocumentAtCommit.mockReturnValue( + Promise.resolve( + Result.ok({ + stream, + lastResponse: Promise.resolve({ + streamType: 'text', + text: 'Hello', + usage, + documentLogUuid, + providerLog: { + providerId: provider.id, + model: MODEL, + messages: [ + { + role: MessageRole.assistant, + content: 'Hello', + toolCalls: [], + }, + ], + }, + }), + }), + ), + ) + + const res = await app.request(route, { + method: 'POST', + body: JSON.stringify({ + path: 'path/to/document', + parameters: {}, + customIdentifier: 'user-456', + }), + headers, + }) + + expect(res.status).toBe(200) + expect(mocks.routeRequest).toHaveBeenCalledWith(abTest, 'user-456') + expect(mocks.getCommitById).toHaveBeenCalledWith(challengerCommit.id) + expect(mocks.runDocumentAtCommit).toHaveBeenCalledWith( + expect.objectContaining({ + source: LogSources.ABTestChallenger, + commit: expect.objectContaining({ id: challengerCommit.id }), + }), + ) + }) + + it('should work without custom identifier for A/B test', async () => { + const abTest = { + id: 1, + uuid: 'test-uuid', + workspaceId: workspace.id, + projectId: project.id, + documentUuid: 'doc-uuid', + challengerCommitId: challengerCommit.id, + testType: 'ab', + trafficPercentage: 50, + status: 'running', + } + + mocks.findActiveForCommit.mockResolvedValue(abTest) + mocks.getHeadCommit.mockResolvedValue(commit) + mocks.routeRequest.mockReturnValue('baseline') + + const documentLogUuid = generateUUIDIdentifier() + const usage = { + promptTokens: 4, + completionTokens: 6, + totalTokens: 10, + inputTokens: 4, + outputTokens: 6, + reasoningTokens: 0, + cachedInputTokens: 0, + } + + await createProviderLog({ + documentLogUuid, + workspace, + providerId: provider.id, + providerType: provider.provider, + model: MODEL, + messages: [ + { role: MessageRole.assistant, content: 'Hello', toolCalls: [] }, + ], + tokens: usage.totalTokens, + }) + + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + mocks.runDocumentAtCommit.mockReturnValue( + Promise.resolve( + Result.ok({ + stream, + lastResponse: Promise.resolve({ + streamType: 'text', + text: 'Hello', + usage, + documentLogUuid, + providerLog: { + providerId: provider.id, + model: MODEL, + messages: [ + { + role: MessageRole.assistant, + content: 'Hello', + toolCalls: [], + }, + ], + }, + }), + }), + ), + ) + + const res = await app.request(route, { + method: 'POST', + body: JSON.stringify({ + path: 'path/to/document', + parameters: {}, + }), + headers, + }) + + expect(res.status).toBe(200) + expect(mocks.routeRequest).toHaveBeenCalledWith(abTest, undefined) + }) + }) + + describe('with background runs and deployment tests', () => { + it('should enqueue background run with shadow test context', async () => { + const shadowTest = { + id: 1, + uuid: 'test-uuid', + workspaceId: workspace.id, + projectId: project.id, + documentUuid: 'doc-uuid', + challengerCommitId: 999, + testType: 'shadow', + trafficPercentage: 100, + status: 'running', + } + + mocks.findActiveForCommit.mockResolvedValue(shadowTest) + mocks.isFeatureEnabledByName.mockImplementation((_, featureName) => { + if (featureName === 'api-background-runs') { + return Promise.resolve(Result.ok(true)) + } + return Promise.resolve(Result.ok(false)) + }) + + mocks.enqueueRun.mockReturnValue( + Promise.resolve( + Result.ok({ + run: { uuid: 'test-run-uuid' }, + }), + ), + ) + + const res = await app.request(route, { + method: 'POST', + body, + headers, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ uuid: 'test-run-uuid' }) + expect(mocks.enqueueRun).toHaveBeenCalledWith( + expect.objectContaining({ + activeDeploymentTest: shadowTest, + }), + ) + }) + }) + }) }) diff --git a/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.ts b/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.ts index 77f99b1dbc..7590b2e8d0 100644 --- a/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.ts +++ b/apps/gateway/src/routes/api/v3/projects/versions/documents/run/run.handler.ts @@ -5,20 +5,28 @@ import { import { captureException } from '$/common/tracer' import { AppRouteHandler } from '$/openApi/types' import { runPresenter } from '$/presenters/runPresenter' -import { LogSources } from '@latitude-data/core/constants' +import { LogSources } from '@latitude-data/constants' import { BadRequestError, LatitudeError } from '@latitude-data/core/lib/errors' import { getUnknownError } from '@latitude-data/core/lib/getUnknownError' import { isAbortError } from '@latitude-data/core/lib/isAbortError' -import { buildClientToolHandlersMap } from '@latitude-data/core/services/documents/tools/clientTools/handlers' import { streamToGenerator } from '@latitude-data/core/lib/streamToGenerator' -import { runDocumentAtCommit } from '@latitude-data/core/services/commits/runDocumentAtCommit' -import { enqueueRun } from '@latitude-data/core/services/runs/enqueue' +import { runForegroundDocument } from '@latitude-data/core/services/commits/foregroundRun' +import { + enqueueRun, + EnqueueRunProps, +} from '@latitude-data/core/services/runs/enqueue' import { isFeatureEnabledByName } from '@latitude-data/core/services/workspaceFeatures/isFeatureEnabledByName' -import { BACKGROUND } from '@latitude-data/core/telemetry' import { streamSSE } from 'hono/streaming' import type { Context } from 'hono' import { RunRoute } from './run.route' -import { ProviderApiKeysRepository } from '@latitude-data/core/repositories' +import { CommitsRepository } from '@latitude-data/core/repositories' +import { routeRequest } from '@latitude-data/core/services/deploymentTests/routeRequest' +import { DeploymentTestsRepository } from '@latitude-data/core/repositories/deploymentTestsRepository' +import { Workspace } from '@latitude-data/core/schema/models/types/Workspace' +import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' +import { Commit } from '@latitude-data/core/schema/models/types/Commit' +import { Project } from '@latitude-data/core/schema/models/types/Project' +import { DeploymentTest } from '@latitude-data/core/schema/models/types/DeploymentTest' // https://github.com/honojs/middleware/issues/735 // https://github.com/orgs/honojs/discussions/1803 @@ -67,6 +75,18 @@ export const runHandler: AppRouteHandler = async (c) => { }) } + const { + activeDeploymentTest: activeTest, + effectiveCommit, + effectiveSource, + } = await resolveDeploymentTestContext({ + workspaceId: workspace.id, + projectId: project.id, + commit, + source, + customIdentifier, + }) + // Check if background execution should happen: // 1. If background prop is explicitly set, use that value // 2. Otherwise, check if the feature flag is enabled for the workspace @@ -83,13 +103,14 @@ export const runHandler: AppRouteHandler = async (c) => { c, workspace, document, - commit, + commit: effectiveCommit, project, parameters, customIdentifier, tools, userMessage, - source, + source: effectiveSource, + activeDeploymentTest: activeTest || undefined, }) } @@ -97,13 +118,15 @@ export const runHandler: AppRouteHandler = async (c) => { c, workspace, document, - commit, + commit: effectiveCommit, + project, parameters, customIdentifier, - source: __internal?.source ?? LogSources.API, + source: effectiveSource, useSSE, tools, - userMessage, + userMessage: userMessage || undefined, + activeDeploymentTest: activeTest || undefined, }) } @@ -118,18 +141,8 @@ async function handleBackgroundRun({ tools, userMessage, source, -}: { - c: Context - workspace: any - document: any - commit: any - project: any - parameters: any - customIdentifier: any - tools: any - userMessage: any - source: any -}) { + activeDeploymentTest, +}: EnqueueRunProps & { c: Context }) { const { run } = await enqueueRun({ document, commit, @@ -140,6 +153,7 @@ async function handleBackgroundRun({ tools, userMessage, source, + activeDeploymentTest, }).then((r) => r.unwrap()) return c.json({ uuid: run.uuid }) @@ -150,25 +164,33 @@ async function handleForegroundRun({ workspace, document, commit, + project, parameters, customIdentifier, source, useSSE, tools, userMessage, + activeDeploymentTest, }: { c: Context - workspace: any - document: any - commit: any - parameters: any - customIdentifier: any - source: any + workspace: Workspace + document: DocumentVersion + commit: Commit + project: Project + parameters: Record + customIdentifier?: string + source: LogSources useSSE: boolean - tools: any - userMessage: any + tools: string[] + userMessage?: string + activeDeploymentTest?: DeploymentTest }) { - const result = await runDocumentAtCommit({ + const { + stream: runStream, + getFinalResponse, + error, + } = await runForegroundDocument({ workspace, document, commit, @@ -176,10 +198,11 @@ async function handleForegroundRun({ customIdentifier, source, abortSignal: c.req.raw.signal, // FIXME: This does not seem to work - context: BACKGROUND({ workspaceId: workspace.id }), - tools: buildClientToolHandlersMap(tools ?? []), + project, + tools, userMessage, - }).then((r) => r.unwrap()) + activeDeploymentTest, + }) if (useSSE) { return streamSSE( @@ -198,7 +221,7 @@ async function handleForegroundRun({ try { for await (const event of streamToGenerator( - result.stream, + runStream, c.req.raw.signal, )) { const data = event.data @@ -237,22 +260,101 @@ async function handleForegroundRun({ ) } - const error = await result.error - if (error) throw error + const finalResponse = await getFinalResponse().catch(async (e) => { + // Ensure pending error promises are awaited to propagate upstream + const pendingError = await error + if (pendingError) throw pendingError + throw e + }) - const response = await result.lastResponse - if (!response) + if (!finalResponse.response) throw new LatitudeError('Stream ended with no error and no content') - const providerScope = new ProviderApiKeysRepository(workspace.id) - const providerUsed = await providerScope - .find(response.providerLog?.providerId) - .then((r) => r.unwrap()) - const body = runPresenter({ - response, - provider: providerUsed, + response: finalResponse.response, + provider: finalResponse.provider, }).unwrap() return c.json(body) } + +/** + * Resolves the deployment test context for a document run request. + * + * Determines the effective commit and log source based on active deployment tests. + * For A/B tests, routes the request to either the baseline or challenger variant + * based on the custom identifier (using consistent hashing). + * + * @param params - The deployment test context parameters + * @param params.workspaceId - The workspace ID + * @param params.projectId - The project ID + * @param params.documentUuid - The document UUID to check for active tests + * @param params.commit - The original commit from the request + * @param params.source - The original log source from the request + * @param params.customIdentifier - Optional custom identifier for A/B test routing + * @returns Object containing the active deployment test (if any), effective commit + * (may differ from original for A/B tests), and effective log source + * (updated to reflect baseline/challenger for A/B tests) + */ +async function resolveDeploymentTestContext({ + workspaceId, + projectId, + commit, + source, + customIdentifier, +}: { + workspaceId: number + projectId: number + commit: any + source: LogSources + customIdentifier?: string | null +}) { + const deploymentTestsRepo = new DeploymentTestsRepository(workspaceId) + const activeDeploymentTest = await deploymentTestsRepo.findActiveForCommit( + projectId, + commit.id, + ) + + if (!activeDeploymentTest || activeDeploymentTest.testType !== 'ab') { + return { + activeDeploymentTest, + effectiveCommit: commit, + effectiveSource: source, + } + } + + // Determine which variant to route to + const routedTo = routeRequest(activeDeploymentTest, customIdentifier) + + // Get the head commit (baseline is always the head commit) + const commitsRepo = new CommitsRepository(workspaceId) + const headCommit = await commitsRepo.getHeadCommit(projectId) + + if (!headCommit) { + // If no head commit, fall back to original commit + return { + activeDeploymentTest, + effectiveCommit: commit, + effectiveSource: source, + } + } + + // Determine the commit and log source based on routing + const commitIdToUse = + routedTo === 'baseline' + ? headCommit.id + : activeDeploymentTest.challengerCommitId + + const effectiveSource = + routedTo === 'baseline' ? source : LogSources.ABTestChallenger + + if (commitIdToUse === commit.id) { + return { activeDeploymentTest, effectiveCommit: commit, effectiveSource } + } + + const effectiveCommit = await commitsRepo + .getCommitById(commitIdToUse) + .then((r) => r.unwrap()) + + return { activeDeploymentTest, effectiveCommit, effectiveSource } +} diff --git a/apps/web/src/actions/deploymentTests/create.ts b/apps/web/src/actions/deploymentTests/create.ts new file mode 100644 index 0000000000..48cb011b7b --- /dev/null +++ b/apps/web/src/actions/deploymentTests/create.ts @@ -0,0 +1,49 @@ +'use server' + +import { withProject, withProjectSchema } from '../procedures' +import { createDeploymentTest } from '@latitude-data/core/services/deploymentTests/create' +import { startDeploymentTest } from '@latitude-data/core/services/deploymentTests/start' +import { CommitsRepository } from '@latitude-data/core/repositories' +import { z } from 'zod' + +export const createDeploymentTestAction = withProject + .inputSchema( + withProjectSchema.extend({ + challengerCommitUuid: z.string(), + testType: z.enum(['shadow', 'ab']), + trafficPercentage: z.number().min(0).max(100).optional(), + }), + ) + .action(async ({ ctx, parsedInput }) => { + const { challengerCommitUuid, testType, trafficPercentage } = parsedInput + + // Fetch challenger commit + const commitsRepo = new CommitsRepository(ctx.workspace.id) + const challengerCommitResult = await commitsRepo.getCommitByUuid({ + uuid: challengerCommitUuid, + projectId: ctx.project.id, + }) + const challengerCommit = challengerCommitResult.unwrap() + + // Create the deployment test (baseline is always the head commit) + const deploymentTestResult = await createDeploymentTest({ + workspaceId: ctx.workspace.id, + projectId: ctx.project.id, + challengerCommitId: challengerCommit.id, + testType, + trafficPercentage: + testType === 'ab' + ? (trafficPercentage ?? 50) + : (trafficPercentage ?? 100), + createdByUserId: ctx.user.id, + }) + const deploymentTest = deploymentTestResult.unwrap() + + // Start the test + const startResult = await startDeploymentTest({ + test: deploymentTest, + }) + startResult.unwrap() + + return deploymentTest + }) diff --git a/apps/web/src/actions/deploymentTests/destroy.ts b/apps/web/src/actions/deploymentTests/destroy.ts new file mode 100644 index 0000000000..078fc9e1e6 --- /dev/null +++ b/apps/web/src/actions/deploymentTests/destroy.ts @@ -0,0 +1,27 @@ +'use server' + +import { z } from 'zod' +import { authProcedure } from '../procedures' +import { destroyDeploymentTest } from '@latitude-data/core/services/deploymentTests/destroy' +import { DeploymentTestsRepository } from '@latitude-data/core/repositories/deploymentTestsRepository' + +export const destroyDeploymentTestAction = authProcedure + .inputSchema( + z.object({ + testUuid: z.string(), + }), + ) + .action(async ({ ctx, parsedInput }) => { + const { testUuid } = parsedInput + + // Fetch the test by UUID to get its ID + const repo = new DeploymentTestsRepository(ctx.workspace.id) + const testResult = await repo.findByUuid(testUuid) + const test = testResult.unwrap() + + const result = await destroyDeploymentTest({ + test, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/actions/deploymentTests/pause.ts b/apps/web/src/actions/deploymentTests/pause.ts new file mode 100644 index 0000000000..d78023ff28 --- /dev/null +++ b/apps/web/src/actions/deploymentTests/pause.ts @@ -0,0 +1,27 @@ +'use server' + +import { z } from 'zod' +import { authProcedure } from '../procedures' +import { pauseDeploymentTest } from '@latitude-data/core/services/deploymentTests/pause' +import { DeploymentTestsRepository } from '@latitude-data/core/repositories/deploymentTestsRepository' + +export const pauseDeploymentTestAction = authProcedure + .inputSchema( + z.object({ + testUuid: z.string(), + }), + ) + .action(async ({ ctx, parsedInput }) => { + const { testUuid } = parsedInput + + // Fetch the test by UUID to get its ID + const repo = new DeploymentTestsRepository(ctx.workspace.id) + const testResult = await repo.findByUuid(testUuid) + const test = testResult.unwrap() + + const result = await pauseDeploymentTest({ + test, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/actions/deploymentTests/resume.ts b/apps/web/src/actions/deploymentTests/resume.ts new file mode 100644 index 0000000000..4d37e7977b --- /dev/null +++ b/apps/web/src/actions/deploymentTests/resume.ts @@ -0,0 +1,27 @@ +'use server' + +import { z } from 'zod' +import { authProcedure } from '../procedures' +import { startDeploymentTest } from '@latitude-data/core/services/deploymentTests/start' +import { DeploymentTestsRepository } from '@latitude-data/core/repositories/deploymentTestsRepository' + +export const resumeDeploymentTestAction = authProcedure + .inputSchema( + z.object({ + testUuid: z.string(), + }), + ) + .action(async ({ ctx, parsedInput }) => { + const { testUuid } = parsedInput + + // Fetch the test by UUID + const repo = new DeploymentTestsRepository(ctx.workspace.id) + const testResult = await repo.findByUuid(testUuid) + const test = testResult.unwrap() + + const result = await startDeploymentTest({ + test, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/actions/deploymentTests/stop.ts b/apps/web/src/actions/deploymentTests/stop.ts new file mode 100644 index 0000000000..1210256d00 --- /dev/null +++ b/apps/web/src/actions/deploymentTests/stop.ts @@ -0,0 +1,27 @@ +'use server' + +import { z } from 'zod' +import { authProcedure } from '../procedures' +import { stopDeploymentTest } from '@latitude-data/core/services/deploymentTests/stop' +import { DeploymentTestsRepository } from '@latitude-data/core/repositories/deploymentTestsRepository' + +export const stopDeploymentTestAction = authProcedure + .inputSchema( + z.object({ + testUuid: z.string(), + }), + ) + .action(async ({ ctx, parsedInput }) => { + const { testUuid } = parsedInput + + // Fetch the test by UUID to get its ID + const repo = new DeploymentTestsRepository(ctx.workspace.id) + const testResult = await repo.findByUuid(testUuid) + const test = testResult.unwrap() + + const result = await stopDeploymentTest({ + test, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ActiveCommitsList.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ActiveCommitsList.tsx new file mode 100644 index 0000000000..0114cf424e --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ActiveCommitsList.tsx @@ -0,0 +1,126 @@ +'use client' + +import React, { useMemo } from 'react' +import { compact } from 'lodash-es' +import { Text } from '@latitude-data/web-ui/atoms/Text' + +import { CommitItem } from './CommitItem' +import { CommitItemsWrapper } from './CommitItemsWrapper' + +import { Commit } from '@latitude-data/core/schema/models/types/Commit' +import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' +import { DeploymentTest } from '@latitude-data/core/schema/models/types/DeploymentTest' + +export type CommitTestInfo = + | { + type: 'ab' + isBaseline: boolean + trafficPercentage: number + } + | { + type: 'shadow' + } + | null + +export function getCommitTestInfo( + commitId: number, + headCommitId: number | undefined, + activeTests: DeploymentTest[], +): CommitTestInfo { + // Baseline is always the head commit, challenger is stored in the test + const isBaseline = headCommitId === commitId + + // First, check for A/B tests (prioritize showing traffic percentage) + for (const test of activeTests) { + if (test.testType === 'ab') { + const isChallenger = test.challengerCommitId === commitId + if (isBaseline || isChallenger) { + // Baseline gets remaining traffic (100 - challenger %), challenger gets its assigned % + const trafficPercentage = isBaseline + ? 100 - (test.trafficPercentage ?? 50) + : (test.trafficPercentage ?? 50) + return { + type: 'ab', + isBaseline, + trafficPercentage, + } + } + } + } + + // Then check for shadow tests + for (const test of activeTests) { + if (test.testType === 'shadow') { + const isChallenger = test.challengerCommitId === commitId + if (isBaseline || isChallenger) { + return { type: 'shadow' } + } + } + } + + return null +} + +export function ActiveCommitsList({ + currentDocument, + headCommit, + commitsInActiveTests, + activeTests, + onCommitPublish, + onCommitDelete, +}: { + currentDocument?: DocumentVersion + headCommit?: Commit + commitsInActiveTests: Commit[] + activeTests: DeploymentTest[] + onCommitPublish: React.Dispatch> + onCommitDelete: React.Dispatch> +}) { + const activeCommits = useMemo(() => { + const commits = new Map() + + // Add head commit if it exists + if (headCommit) { + commits.set(headCommit.id, headCommit) + } + + // Add commits in active test deployments + commitsInActiveTests.forEach((commit) => { + commits.set(commit.id, commit) + }) + + return Array.from(commits.values()) + }, [headCommit, commitsInActiveTests]) + + if (activeCommits.length === 0) { + return ( + + There are no active versions on this project yet. + + ) + } + + return ( + + {compact(activeCommits).map((commit) => { + const testInfo = getCommitTestInfo( + commit.id, + headCommit?.id, + activeTests, + ) + return ( +
  • + +
  • + ) + })} +
    + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ArchivedCommitsList.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ArchivedCommitsList.tsx index 7765c8e570..52b8bd7bb1 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ArchivedCommitsList.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/ArchivedCommitsList.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react' import { Text } from '@latitude-data/web-ui/atoms/Text' import { useCommits } from '$/stores/commitsStore' -import { CommitItem, CommitItemSkeleton, SimpleUser } from './CommitItem' +import { CommitItem, CommitItemSkeleton } from './CommitItem' import { CommitItemsWrapper } from './CommitItemsWrapper' import { CommitStatus } from '@latitude-data/core/constants' @@ -14,11 +14,9 @@ import { DocumentVersion } from '@latitude-data/core/schema/models/types/Documen export function ArchivedCommitsList({ currentDocument, headCommit, - usersById, }: { currentDocument?: DocumentVersion headCommit?: Commit - usersById: Record }) { const { data, isLoading } = useCommits({ commitStatus: CommitStatus.Merged, @@ -51,11 +49,7 @@ export function ArchivedCommitsList({ {commits.map((commit) => (
  • - +
  • ))}
    diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CommitItem/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CommitItem/index.tsx index 1b81d5ccf4..3c54ad4115 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CommitItem/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CommitItem/index.tsx @@ -1,10 +1,12 @@ 'use client' + import { useMemo } from 'react' import { ROUTES } from '$/services/routes' import { Badge } from '@latitude-data/web-ui/atoms/Badge' import { Button } from '@latitude-data/web-ui/atoms/Button' import { Text } from '@latitude-data/web-ui/atoms/Text' +import { Tooltip } from '@latitude-data/web-ui/atoms/Tooltip' import { ReactStateDispatch } from '@latitude-data/web-ui/commonTypes' import { useCurrentCommit } from '$/app/providers/CommitProvider' import { useCurrentProject } from '$/app/providers/ProjectProvider' @@ -16,6 +18,7 @@ import { HEAD_COMMIT } from '@latitude-data/core/constants' import { Commit } from '@latitude-data/core/schema/models/types/Commit' import { User } from '@latitude-data/core/schema/models/types/User' import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' +import type { CommitTestInfo } from '../ActiveCommitsList' export type SimpleUser = Omit export enum BadgeType { @@ -27,19 +30,47 @@ export enum BadgeType { export function BadgeCommit({ commit, isLive, + testInfo, }: { commit?: Commit isLive: boolean + testInfo?: CommitTestInfo | null }) { - const text = isLive - ? 'Live' - : commit?.mergedAt - ? `v${commit?.version}` - : 'Draft' + // Head commit always shows "Live" label regardless of tests + if (isLive) { + return ( + + Live + + ) + } + + // If there's test info for non-head commits, show test badge + if (testInfo) { + if (testInfo.type === 'shadow') { + return ( + + Shadow test + + ) + } + if (testInfo.type === 'ab') { + // Challenger gets warning badge + const variant = 'warningMuted' + return ( + + A/B test: {testInfo.trafficPercentage}% + + ) + } + } + + // Default badge behavior for non-head commits + const text = commit?.mergedAt ? `v${commit?.version}` : 'Draft' return ( {text} @@ -50,14 +81,14 @@ export function CommitItem({ commit, currentDocument, headCommitId, - user, + testInfo, onCommitPublish, onCommitDelete, }: { commit?: Commit currentDocument?: DocumentVersion headCommitId?: number - user?: SimpleUser + testInfo?: CommitTestInfo | null onCommitPublish?: ReactStateDispatch onCommitDelete?: ReactStateDispatch }) { @@ -88,47 +119,79 @@ export function CommitItem({ if (!commit || !commitPath) return null + const hasViewButton = currentCommit.uuid !== commit.uuid + const hasDraftButtons = isDraft && onCommitPublish && onCommitDelete + const hasAnyButton = hasViewButton || hasDraftButtons + return (
    -
    -
    +
    +
    {commit.title} - +
    + +
    - - {user ? user.name : 'Unknown user'} - -
    -
    - {currentCommit.uuid !== commit.uuid && ( - - - + {hasAnyButton && ( +
    +
    + {hasViewButton && ( + +
    +
    )} - {isDraft && onCommitPublish && onCommitDelete ? ( - <> - - - - ) : null}
    ) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CurrentCommitsList.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/DraftsCommitsList.tsx similarity index 60% rename from apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CurrentCommitsList.tsx rename to apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/DraftsCommitsList.tsx index e4e322be70..3a7742774c 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/CurrentCommitsList.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/DraftsCommitsList.tsx @@ -1,27 +1,30 @@ 'use client' import { compact } from 'lodash-es' +import { useMemo } from 'react' import { ReactStateDispatch } from '@latitude-data/web-ui/commonTypes' +import { Text } from '@latitude-data/web-ui/atoms/Text' import { useCommits } from '$/stores/commitsStore' -import { CommitItem, CommitItemSkeleton, SimpleUser } from './CommitItem' +import { CommitItem, CommitItemSkeleton } from './CommitItem' import { CommitItemsWrapper } from './CommitItemsWrapper' import { CommitStatus } from '@latitude-data/core/constants' import { Commit } from '@latitude-data/core/schema/models/types/Commit' import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' -export function CurrentCommitsList({ + +export function DraftsCommitsList({ currentDocument, headCommit, draftCommits, - usersById, + commitsInActiveTests, onCommitPublish, onCommitDelete, }: { currentDocument?: DocumentVersion headCommit?: Commit draftCommits: Commit[] - usersById: Record + commitsInActiveTests: Commit[] onCommitPublish: ReactStateDispatch onCommitDelete: ReactStateDispatch }) { @@ -30,6 +33,19 @@ export function CurrentCommitsList({ commitStatus: CommitStatus.Draft, }) + // Filter out commits that are in the active tab (head commit or in active tests) + const filteredDrafts = useMemo(() => { + const activeCommitIds = new Set() + if (headCommit) { + activeCommitIds.add(headCommit.id) + } + commitsInActiveTests.forEach((commit) => { + activeCommitIds.add(commit.id) + }) + + return compact(drafts).filter((commit) => !activeCommitIds.has(commit.id)) + }, [drafts, headCommit, commitsInActiveTests]) + if (isLoading) { return ( @@ -40,25 +56,22 @@ export function CurrentCommitsList({ ) } + if (filteredDrafts.length === 0) { + return ( + + There are no draft versions on this project yet. + + ) + } + return ( - {headCommit && ( - - )} - {compact(drafts).map((commit) => ( + {filteredDrafts.map((commit) => (
  • diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/index.tsx index 7262b1aa06..99a3bd6578 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/CommitSelector/index.tsx @@ -17,13 +17,12 @@ import { SelectTrigger, SelectValueWithIcon, } from '@latitude-data/web-ui/atoms/Select' -import useUsers from '$/stores/users' - import CreateDraftCommitModal from '../CreateDraftCommitModal' import PublishDraftCommitModal from '../PublishDraftCommitModal' import { ArchivedCommitsList } from './ArchivedCommitsList' -import { BadgeCommit, BadgeType, SimpleUser } from './CommitItem' -import { CurrentCommitsList } from './CurrentCommitsList' +import { ActiveCommitsList, getCommitTestInfo } from './ActiveCommitsList' +import { BadgeCommit, BadgeType } from './CommitItem' +import { DraftsCommitsList } from './DraftsCommitsList' import DeleteDraftCommitModal from './DeleteDraftCommitModal' import { OpenInDocsButton } from '$/components/Documentation/OpenInDocsButton' import { DocsRoute } from '$/components/Documentation/routes' @@ -32,6 +31,7 @@ import { HELP_CENTER } from '@latitude-data/core/constants' import { Commit } from '@latitude-data/core/schema/models/types/Commit' import { DocumentVersion } from '@latitude-data/core/schema/models/types/DocumentVersion' +import { DeploymentTest } from '@latitude-data/core/schema/models/types/DeploymentTest' const MIN_WIDTH_SELECTOR_PX = 380 const TRIGGER_X_PADDING_PX = 26 @@ -117,26 +117,19 @@ export default function CommitSelector({ currentCommit, currentDocument, draftCommits, + commitsInActiveTests, + activeTests, }: { headCommit?: Commit | undefined currentCommit: Commit currentDocument?: DocumentVersion draftCommits: Commit[] + commitsInActiveTests: Commit[] + activeTests: DeploymentTest[] }) { const [open, setOpen] = useState(false) const { ref, maxHeight, calculateMaxHeight } = useCalculateMaxHeight() const width = useObserveSelectWidth(ref) - const { data: users } = useUsers() - const usersById = useMemo(() => { - return users.reduce( - (acc, user) => { - acc[user.id] = user - return acc - }, - {} as Record, - ) - }, [users]) - const selected = useMemo(() => { return { commit: currentCommit, @@ -149,16 +142,30 @@ export default function CommitSelector({ : BadgeType.Draft, } }, [currentCommit, headCommit]) - + const currentCommitTestInfo = useMemo(() => { + return getCommitTestInfo(currentCommit.id, headCommit?.id, activeTests) + }, [currentCommit.id, headCommit?.id, activeTests]) const [publishCommit, setPublishCommit] = useState(null) const [deleteCommit, setDeleteCommit] = useState(null) - const canPublish = !currentCommit.mergedAt - const [selectedTab, setSelectedTab] = useState<'current' | 'archived'>( - !currentCommit.mergedAt || currentCommit.id == headCommit?.id - ? 'current' - : 'archived', - ) + const getInitialTab = (): 'active' | 'drafts' | 'archived' => { + if (currentCommit.mergedAt && currentCommit.id !== headCommit?.id) { + return 'archived' + } + // Check if current commit is head or in active tests + const isHead = currentCommit.id === headCommit?.id + const isInActiveTest = commitsInActiveTests.some( + (c) => c.id === currentCommit.id, + ) + if (isHead || isInActiveTest) { + return 'active' + } + return 'drafts' + } + const [selectedTab, setSelectedTab] = useState< + 'active' | 'drafts' | 'archived' + >(getInitialTab()) + return (
    } > @@ -192,26 +200,35 @@ export default function CommitSelector({ - {selectedTab === 'current' ? ( - + ) : selectedTab === 'drafts' ? ( + ) : ( )} @@ -222,7 +239,7 @@ export default function CommitSelector({ fullWidth onClick={() => setPublishCommit(currentCommit.id)} > - Publish new version + Deploy version ) : null} {currentCommit.mergedAt ? ( @@ -243,6 +260,7 @@ export default function CommitSelector({ ) : null}
    diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/TestingSection.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/TestingSection.tsx new file mode 100644 index 0000000000..5f14490ac2 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/TestingSection.tsx @@ -0,0 +1,112 @@ +'use client' + +import { SwitchInput } from '@latitude-data/web-ui/atoms/Switch' +import { TabSelector } from '$/components/TabSelector' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { Slider } from '@latitude-data/web-ui/atoms/Slider' +import { FormWrapper } from '@latitude-data/web-ui/atoms/FormWrapper' + +interface TestingSectionProps { + enabled: boolean + testType: 'shadow' | 'ab' | null + trafficPercentage: number + onEnabledChange: (enabled: boolean) => void + onTestTypeChange: (testType: 'shadow' | 'ab') => void + onTrafficPercentageChange: (percentage: number) => void +} + +export function TestingSection({ + enabled, + testType, + trafficPercentage, + onEnabledChange, + onTestTypeChange, + onTrafficPercentageChange, +}: TestingSectionProps) { + return ( +
    +
    + +
    +
    +
    + onTestTypeChange(value as 'shadow' | 'ab')} + disabled={!enabled} + fullWidth + /> + {testType && ( + + {testType === 'shadow' + ? 'Run the new version alongside production without affecting users' + : 'Split traffic between the current version and the new version'} + + )} +
    + + {testType && ( +
    +
    + Traffic percentage + + {testType === 'shadow' + ? 'Percentage of traffic to shadow test' + : 'Percentage of traffic to route to the new version'} + +
    +
    + + 0% + +
    + + onTrafficPercentageChange(value[0]!) + } + /> +
    + + 100% + +
    +
    + + {trafficPercentage}% + +
    +
    + )} +
    +
    +
    + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/index.tsx index 74aca543dc..0ea2878af9 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/PublishDraftCommitModal/index.tsx @@ -1,5 +1,12 @@ -import { useEffect, useMemo, useState } from 'react' -import { ConfirmModal } from '@latitude-data/web-ui/atoms/Modal' +import { + useCallback, + useEffect, + useMemo, + useState, + type FormEvent, +} from 'react' +import { Button } from '@latitude-data/web-ui/atoms/Button' +import { CloseTrigger, Modal } from '@latitude-data/web-ui/atoms/Modal' import { FormWrapper } from '@latitude-data/web-ui/atoms/FormWrapper' import { Input } from '@latitude-data/web-ui/atoms/Input' import { ReactStateDispatch } from '@latitude-data/web-ui/commonTypes' @@ -11,53 +18,28 @@ import { ROUTES } from '$/services/routes' import { useCommits } from '$/stores/commitsStore' import { useRouter } from 'next/navigation' import { useCommitsChanges } from '$/stores/commitChanges' +import useFeature from '$/stores/useFeature' import { ChangedDocument, type CommitChanges } from '@latitude-data/constants' import { ChangesList } from './ChangesList' import { ChangeDiff } from './ChangeDiff' import { CommitStatus } from '@latitude-data/core/constants' import { MainDocumentChange } from './MainDocumentChange' import useDocumentVersions from '$/stores/documentVersions' - -function BlankSlateSelection() { - return ( -
    - - Select a prompt to view the diff - -
    - ) -} - -function confirmDescription({ - isLoading, - changes, - title, -}: { - changes: CommitChanges - isLoading: boolean - title: string -}) { - if (isLoading) return undefined - if (!changes.anyChanges) return 'No changes to publish.' - if (!title.trim()) return 'Please provide a version name.' - - if (changes.documents.hasErrors) { - return 'Some documents have errors, please click on those documents to see the errors.' - } - - if (changes.triggers.hasPending) { - return `There are triggers that needs to be configured before publishing.` - } - - return 'Publishing a new version is reversible and doesnt remove previous versions! You can always go back to use previous version if needed.' -} +import { Commit } from '@latitude-data/core/schema/models/types/Commit' +import { TestingSection } from './TestingSection' +import useLatitudeAction from '$/hooks/useLatitudeAction' +import { createDeploymentTestAction } from '$/actions/deploymentTests/create' +import useDeploymentTests from '$/stores/deploymentTests' +import type { DeploymentTest } from '@latitude-data/core/schema/models/types/DeploymentTest' export default function PublishDraftCommitModal({ commitId, onClose, + headCommit, }: { commitId: number | null onClose: ReactStateDispatch + headCommit?: Commit }) { const { toast } = useToast() const { data, publishDraft, isPublishing } = useCommits({ @@ -84,9 +66,80 @@ export default function PublishDraftCommitModal({ const [selectedDocumentChange, setSelectedDocumentChange] = useState< ChangedDocument | undefined >(undefined) + const { isEnabled: testVersionEnabled } = useFeature('testing') + const [testingEnabled, setTestingEnabled] = useState(false) + const [testType, setTestType] = useState<'shadow' | 'ab' | null>('shadow') + const [trafficPercentage, setTrafficPercentage] = useState(100) const { data: changes, isLoading: isLoadingChanges } = useCommitsChanges({ commit, }) + const { data: documents, isLoading: isLoadingDocuments } = + useDocumentVersions({ + projectId: project.id, + commitUuid: commit?.uuid, + }) + const { data: deploymentTests, isLoading: isDeploymentTests } = + useDeploymentTests({ + projectId: project.id, + }) + const { execute: createTest, isPending: isCreatingTest } = useLatitudeAction( + createDeploymentTestAction, + { + onSuccess: () => { + toast({ + title: 'Success', + description: 'Deployment test created successfully', + }) + + if (commit) { + router.push( + ROUTES.projects + .detail({ id: project.id }) + .commits.detail({ uuid: commit?.uuid }).root, + ) + } + }, + onError: (error) => { + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }) + }, + }, + ) + const isLoading = isLoadingDocuments || isDeploymentTests + const isPublishingOrCreating = isPublishing || isCreatingTest + + const { commitInActiveDeployment, hasBothTestTypesActive } = + useDeploymentTestsState(deploymentTests, commit?.id) + + const shouldShowTestingSection = useTestingSectionVisibility( + testVersionEnabled, + headCommit, + commit, + isLoading, + commitInActiveDeployment, + hasBothTestTypesActive, + ) + + const { + anyChanges, + hasErrors, + disabled: formDisabled, + } = usePublishFormState( + changes, + isLoadingChanges, + isPublishing, + isCreatingTest, + testingEnabled, + headCommit, + commit, + title, + ) + + const disabled = formDisabled || (testingEnabled && !testType) + useEffect(() => { if (commit) { setTitle(commit.title || '') @@ -94,29 +147,36 @@ export default function PublishDraftCommitModal({ } }, [commit]) - const { data: documents, isLoading: isLoadingDocuments } = - useDocumentVersions({ - projectId: project.id, - commitUuid: commit?.uuid, - }) + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault() - const isLoading = isLoadingChanges || isLoadingDocuments + if (testingEnabled && testVersionEnabled) { + if (!headCommit) { + toast({ + title: 'Error', + description: 'No published version found to use as baseline', + variant: 'destructive', + }) + return + } - const anyChanges = changes.anyChanges - const hasErrors = (!isLoadingChanges && changes.hasIssues) || !anyChanges + if (!testType || !commit) { + toast({ + title: 'Error', + description: 'Please complete all required fields', + variant: 'destructive', + }) + return + } - return ( - onClose(null)} - onConfirm={() => + await createTest({ + projectId: project.id, + challengerCommitUuid: commit.uuid, + testType, + trafficPercentage, + }) + } else { publishDraft({ projectId: project.id, id: commitId!, @@ -124,50 +184,110 @@ export default function PublishDraftCommitModal({ description, }) } - confirm={{ - label: isLoading ? 'Validating...' : 'Publish new version', - description: confirmDescription({ isLoading, changes, title }), - disabled: - isLoading || - !changes.anyChanges || - changes.hasIssues || - !title.trim(), - isConfirming: isPublishing, - }} + }, + [ + testingEnabled, + testVersionEnabled, + headCommit, + testType, + commit, + trafficPercentage, + createTest, + project.id, + publishDraft, + commitId, + title, + description, + toast, + ], + ) + + return ( + onClose(null)} + description={confirmDescription({ isLoading, changes, title })} + footer={ + <> + + + + } >
    -
    - - setTitle(e.target.value)} - placeholder='Enter version name' - /> -