6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { loggerMock } from '@sim/testing'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
|
|
import type { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
|
|
|
const {
|
|
captureServerEventMock,
|
|
executeWorkflowCoreMock,
|
|
handlePostExecutionPauseStateMock,
|
|
loggingSessionConstructorMock,
|
|
projectDiagnosticErrorMock,
|
|
safeStartMock,
|
|
waitForPostExecutionMock,
|
|
setTrustedExecutionCorrelationMock,
|
|
} = vi.hoisted(() => ({
|
|
captureServerEventMock: vi.fn(),
|
|
executeWorkflowCoreMock: vi.fn(),
|
|
handlePostExecutionPauseStateMock: vi.fn(),
|
|
loggingSessionConstructorMock: vi.fn(),
|
|
projectDiagnosticErrorMock: vi.fn(),
|
|
safeStartMock: vi.fn(),
|
|
waitForPostExecutionMock: vi.fn(),
|
|
setTrustedExecutionCorrelationMock: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@sim/utils/id', () => ({
|
|
generateId: () => 'execution-1',
|
|
}))
|
|
|
|
vi.mock('@/lib/logs/execution/logging-session', () => ({
|
|
LoggingSession: class {
|
|
projectDiagnosticError = projectDiagnosticErrorMock
|
|
safeStart = safeStartMock
|
|
waitForPostExecution = waitForPostExecutionMock
|
|
setTrustedExecutionCorrelation = setTrustedExecutionCorrelationMock
|
|
|
|
constructor(...args: unknown[]) {
|
|
loggingSessionConstructorMock(...args)
|
|
}
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/posthog/server', () => ({
|
|
captureServerEvent: captureServerEventMock,
|
|
}))
|
|
|
|
vi.mock('@/lib/workflows/executor/execution-core', () => ({
|
|
executeWorkflowCore: executeWorkflowCoreMock,
|
|
}))
|
|
|
|
vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
|
|
handlePostExecutionPauseState: handlePostExecutionPauseStateMock,
|
|
}))
|
|
|
|
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
|
|
|
|
const workflowExecutionLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
|
|
([name]) => name === 'WorkflowExecution'
|
|
)
|
|
const workflowExecutionLogger =
|
|
loggerMock.createLogger.mock.results[workflowExecutionLoggerCallIndex]?.value
|
|
if (!workflowExecutionLogger) throw new Error('WorkflowExecution logger mock was not initialized')
|
|
|
|
const billingAttribution: BillingAttributionSnapshot = {
|
|
actorUserId: 'actor-1',
|
|
workspaceId: 'workspace-1',
|
|
organizationId: 'org-1',
|
|
billedAccountUserId: 'owner-1',
|
|
billingEntity: { type: 'organization', id: 'org-1' },
|
|
billingPeriod: {
|
|
start: '2026-07-01T00:00:00.000Z',
|
|
end: '2026-08-01T00:00:00.000Z',
|
|
},
|
|
payerSubscription: {
|
|
id: 'subscription-1',
|
|
referenceId: 'org-1',
|
|
plan: 'team',
|
|
status: 'active',
|
|
seats: 5,
|
|
periodStart: '2026-07-01T00:00:00.000Z',
|
|
periodEnd: '2026-08-01T00:00:00.000Z',
|
|
},
|
|
}
|
|
|
|
const workflow = {
|
|
id: 'workflow-1',
|
|
userId: 'owner-1',
|
|
workspaceId: 'workspace-1',
|
|
variables: {},
|
|
}
|
|
|
|
describe('executeWorkflow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
safeStartMock.mockResolvedValue(true)
|
|
waitForPostExecutionMock.mockResolvedValue(undefined)
|
|
projectDiagnosticErrorMock.mockImplementation(
|
|
(error: unknown, details: Record<string, unknown> = {}) => ({
|
|
...details,
|
|
errorType: error instanceof Error ? 'error' : typeof error,
|
|
hasStack: error instanceof Error && typeof error.stack === 'string',
|
|
})
|
|
)
|
|
handlePostExecutionPauseStateMock.mockResolvedValue(undefined)
|
|
executeWorkflowCoreMock.mockImplementation(
|
|
async (params: {
|
|
snapshot: ExecutionSnapshot
|
|
loggingSession: { safeStart: (startParams: unknown) => Promise<boolean> }
|
|
}) => {
|
|
await params.loggingSession.safeStart({
|
|
userId: params.snapshot.metadata.userId,
|
|
billingAttribution: params.snapshot.metadata.billingAttribution,
|
|
workspaceId: params.snapshot.metadata.workspaceId,
|
|
})
|
|
return {
|
|
success: true,
|
|
output: { ok: true },
|
|
logs: [],
|
|
metadata: { duration: 10 },
|
|
status: 'completed',
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
it('rejects workspace execution without immutable billing attribution', async () => {
|
|
await expect(
|
|
executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
})
|
|
).rejects.toThrow('Billing attribution is required for workspace execution')
|
|
|
|
expect(executeWorkflowCoreMock).not.toHaveBeenCalled()
|
|
expect(safeStartMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
['actor', { ...billingAttribution, actorUserId: 'other-actor' }],
|
|
['workspace', { ...billingAttribution, workspaceId: 'other-workspace' }],
|
|
])('rejects a billing attribution %s mismatch', async (_scope, mismatchedAttribution) => {
|
|
await expect(
|
|
executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
billingAttribution: mismatchedAttribution,
|
|
})
|
|
).rejects.toThrow('Workflow billing attribution does not match its actor and workspace')
|
|
|
|
expect(executeWorkflowCoreMock).not.toHaveBeenCalled()
|
|
expect(safeStartMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('asserts the billing attribution snapshot before execution', async () => {
|
|
const malformedAttribution = {
|
|
...billingAttribution,
|
|
billingPeriod: undefined,
|
|
} as unknown as BillingAttributionSnapshot
|
|
|
|
await expect(
|
|
executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
billingAttribution: malformedAttribution,
|
|
})
|
|
).rejects.toThrow('Billing attribution snapshot is missing its billing period')
|
|
|
|
expect(executeWorkflowCoreMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('propagates validated attribution through execution metadata to logger startup', async () => {
|
|
await executeWorkflow(workflow, 'request-1', { prompt: 'hello' }, 'actor-1', {
|
|
enabled: true,
|
|
workflowTriggerType: 'copilot',
|
|
billingAttribution,
|
|
})
|
|
|
|
const coreParams = executeWorkflowCoreMock.mock.calls[0]?.[0] as {
|
|
snapshot: ExecutionSnapshot
|
|
}
|
|
expect(coreParams.snapshot.metadata.billingAttribution).toEqual(billingAttribution)
|
|
expect(Object.isFrozen(coreParams.snapshot.metadata.billingAttribution)).toBe(true)
|
|
expect(safeStartMock).toHaveBeenCalledWith({
|
|
userId: 'actor-1',
|
|
billingAttribution,
|
|
workspaceId: 'workspace-1',
|
|
})
|
|
expect(loggingSessionConstructorMock).toHaveBeenCalledWith(
|
|
'workflow-1',
|
|
'execution-1',
|
|
'copilot',
|
|
'request-1'
|
|
)
|
|
})
|
|
|
|
it('forwards trusted initial trace-secret provenance to the execution core', async () => {
|
|
const provenance = {
|
|
version: 1 as const,
|
|
complete: true,
|
|
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
|
scope: { userId: 'actor-1', workspaceId: 'workspace-1' },
|
|
}
|
|
|
|
await executeWorkflow(workflow, 'request-1', { prompt: 'hello' }, 'actor-1', {
|
|
enabled: true,
|
|
billingAttribution,
|
|
trustedInitialResolvedSecretTraceProvenance: provenance,
|
|
})
|
|
|
|
expect(executeWorkflowCoreMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ trustedInitialResolvedSecretTraceProvenance: provenance })
|
|
)
|
|
})
|
|
|
|
it('waits for post-execution persistence before resolving', async () => {
|
|
let resolvePostExecution!: () => void
|
|
waitForPostExecutionMock.mockReturnValueOnce(
|
|
new Promise<void>((resolve) => {
|
|
resolvePostExecution = resolve
|
|
})
|
|
)
|
|
|
|
let executionSettled = false
|
|
const executionPromise = executeWorkflow(
|
|
workflow,
|
|
'request-1',
|
|
{ prompt: 'hello' },
|
|
'actor-1',
|
|
{
|
|
enabled: true,
|
|
billingAttribution,
|
|
}
|
|
).then((result) => {
|
|
executionSettled = true
|
|
return result
|
|
})
|
|
|
|
await vi.waitFor(() => expect(waitForPostExecutionMock).toHaveBeenCalledOnce())
|
|
expect(executionSettled).toBe(false)
|
|
|
|
resolvePostExecution()
|
|
await executionPromise
|
|
|
|
expect(executionSettled).toBe(true)
|
|
})
|
|
|
|
it('waits for post-execution persistence before rejecting', async () => {
|
|
const executionError = new Error('Request body size limit exceeded (10MB)')
|
|
executeWorkflowCoreMock.mockRejectedValueOnce(executionError)
|
|
|
|
let resolvePostExecution!: () => void
|
|
waitForPostExecutionMock.mockReturnValueOnce(
|
|
new Promise<void>((resolve) => {
|
|
resolvePostExecution = resolve
|
|
})
|
|
)
|
|
|
|
let executionSettled = false
|
|
const executionPromise = executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
billingAttribution,
|
|
}).catch((error: unknown) => {
|
|
executionSettled = true
|
|
throw error
|
|
})
|
|
|
|
await vi.waitFor(() => expect(waitForPostExecutionMock).toHaveBeenCalledOnce())
|
|
expect(executionSettled).toBe(false)
|
|
|
|
resolvePostExecution()
|
|
await expect(executionPromise).rejects.toBe(executionError)
|
|
expect(executionSettled).toBe(true)
|
|
})
|
|
|
|
it('transfers post-execution ownership with successful streaming metadata', async () => {
|
|
const result = await executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
skipLoggingComplete: true,
|
|
billingAttribution,
|
|
})
|
|
|
|
expect(waitForPostExecutionMock).not.toHaveBeenCalled()
|
|
expect(result._streamingMetadata?.loggingSession).toBeDefined()
|
|
})
|
|
|
|
it('retains post-execution ownership when streaming execution rejects', async () => {
|
|
const executionError = new Error('Streaming execution failed')
|
|
executeWorkflowCoreMock.mockRejectedValueOnce(executionError)
|
|
|
|
await expect(
|
|
executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
skipLoggingComplete: true,
|
|
billingAttribution,
|
|
})
|
|
).rejects.toBe(executionError)
|
|
|
|
expect(waitForPostExecutionMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('persists server-issued workflow-group correlation in execution metadata', async () => {
|
|
const correlation = {
|
|
executionId: 'execution-1',
|
|
requestId: 'wfgrp-execution-1',
|
|
source: 'workflow_group' as const,
|
|
workflowId: 'workflow-1',
|
|
triggerType: 'table',
|
|
tableId: 'table-1',
|
|
rowId: 'row-1',
|
|
groupId: 'group-1',
|
|
}
|
|
|
|
await executeWorkflow(workflow, 'request-1', { rowId: 'row-1' }, 'actor-1', {
|
|
enabled: true,
|
|
workflowTriggerType: 'table',
|
|
billingAttribution,
|
|
trustedExecutionCorrelation: correlation,
|
|
})
|
|
|
|
const coreParams = executeWorkflowCoreMock.mock.calls[0]?.[0] as {
|
|
snapshot: ExecutionSnapshot
|
|
}
|
|
expect(coreParams.snapshot.metadata.correlation).toEqual(correlation)
|
|
expect(setTrustedExecutionCorrelationMock).toHaveBeenCalledWith(correlation)
|
|
})
|
|
|
|
it('uses the shared diagnostic projection for operational logs and failure telemetry', async () => {
|
|
const secret = 'workflow-telemetry-secret-7f3a91'
|
|
const error = new Error(`failed ${secret} __var_API_KEY __sim_code_1_binding_0`)
|
|
const projectedError = 'failed {{API_KEY}} {{API_KEY}} [RUNTIME_BINDING]'
|
|
executeWorkflowCoreMock.mockRejectedValueOnce(error)
|
|
projectDiagnosticErrorMock.mockReturnValueOnce({ error: projectedError, errorName: 'Error' })
|
|
|
|
await expect(
|
|
executeWorkflow(workflow, 'request-1', undefined, 'actor-1', {
|
|
enabled: true,
|
|
billingAttribution,
|
|
})
|
|
).rejects.toBe(error)
|
|
|
|
expect(workflowExecutionLogger.error).toHaveBeenCalledWith(
|
|
'[request-1] Workflow execution failed',
|
|
{ error: projectedError, errorName: 'Error' }
|
|
)
|
|
expect(captureServerEventMock).toHaveBeenCalledWith(
|
|
'actor-1',
|
|
'workflow_execution_failed',
|
|
expect.objectContaining({ error_message: projectedError }),
|
|
expect.anything()
|
|
)
|
|
const observabilityPayload = JSON.stringify({
|
|
logger: workflowExecutionLogger.error.mock.calls,
|
|
telemetry: captureServerEventMock.mock.calls,
|
|
})
|
|
expect(observabilityPayload).not.toContain(secret)
|
|
expect(observabilityPayload).not.toContain('__var_')
|
|
expect(observabilityPayload).not.toContain('__sim_')
|
|
expect(error.message).toContain(secret)
|
|
})
|
|
})
|