d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
748 lines
25 KiB
TypeScript
748 lines
25 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const dbMocks = vi.hoisted(() => {
|
|
const selectLimit = vi.fn()
|
|
const selectWhere = vi.fn()
|
|
const selectFrom = vi.fn()
|
|
const select = vi.fn()
|
|
const updateWhere = vi.fn()
|
|
const updateSet = vi.fn()
|
|
const update = vi.fn()
|
|
const execute = vi.fn()
|
|
const eq = vi.fn()
|
|
const and = vi.fn((...args: unknown[]) => ({ type: 'and', args }))
|
|
const sql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }))
|
|
|
|
select.mockReturnValue({ from: selectFrom })
|
|
selectFrom.mockReturnValue({ where: selectWhere })
|
|
selectWhere.mockReturnValue({ limit: selectLimit })
|
|
|
|
update.mockReturnValue({ set: updateSet })
|
|
updateSet.mockReturnValue({ where: updateWhere })
|
|
|
|
return {
|
|
select,
|
|
selectFrom,
|
|
selectWhere,
|
|
selectLimit,
|
|
update,
|
|
updateSet,
|
|
updateWhere,
|
|
execute,
|
|
eq,
|
|
and,
|
|
sql,
|
|
}
|
|
})
|
|
|
|
const {
|
|
completeWorkflowExecutionMock,
|
|
startWorkflowExecutionMock,
|
|
loadWorkflowStateForExecutionMock,
|
|
} = vi.hoisted(() => ({
|
|
completeWorkflowExecutionMock: vi.fn(),
|
|
startWorkflowExecutionMock: vi.fn(),
|
|
loadWorkflowStateForExecutionMock: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => ({
|
|
db: {
|
|
select: dbMocks.select,
|
|
update: dbMocks.update,
|
|
execute: dbMocks.execute,
|
|
},
|
|
}))
|
|
|
|
vi.mock('drizzle-orm', () => ({
|
|
eq: dbMocks.eq,
|
|
and: dbMocks.and,
|
|
sql: dbMocks.sql,
|
|
}))
|
|
|
|
vi.mock('@/lib/logs/execution/logger', () => ({
|
|
executionLogger: {
|
|
startWorkflowExecution: startWorkflowExecutionMock,
|
|
completeWorkflowExecution: completeWorkflowExecutionMock,
|
|
},
|
|
}))
|
|
|
|
const {
|
|
setLastStartedBlockMock,
|
|
setLastCompletedBlockMock,
|
|
getProgressMarkersMock,
|
|
clearProgressMarkersMock,
|
|
} = vi.hoisted(() => ({
|
|
setLastStartedBlockMock: vi.fn().mockResolvedValue(false),
|
|
setLastCompletedBlockMock: vi.fn().mockResolvedValue(false),
|
|
getProgressMarkersMock: vi.fn().mockResolvedValue({}),
|
|
clearProgressMarkersMock: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/lib/logs/execution/progress-markers', () => ({
|
|
setLastStartedBlock: setLastStartedBlockMock,
|
|
setLastCompletedBlock: setLastCompletedBlockMock,
|
|
getProgressMarkers: getProgressMarkersMock,
|
|
clearProgressMarkers: clearProgressMarkersMock,
|
|
}))
|
|
|
|
vi.mock('@/lib/logs/execution/logging-factory', () => ({
|
|
calculateCostSummary: vi.fn().mockReturnValue({
|
|
totalCost: 0,
|
|
totalInputCost: 0,
|
|
totalOutputCost: 0,
|
|
totalTokens: 0,
|
|
totalPromptTokens: 0,
|
|
totalCompletionTokens: 0,
|
|
baseExecutionCharge: 0,
|
|
models: {},
|
|
}),
|
|
createEnvironmentObject: vi.fn(),
|
|
createTriggerObject: vi.fn(),
|
|
loadDeployedWorkflowStateForLogging: vi.fn(),
|
|
loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock,
|
|
}))
|
|
|
|
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
|
|
import { LoggingSession } from './logging-session'
|
|
|
|
describe('LoggingSession start snapshots', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
startWorkflowExecutionMock.mockResolvedValue({})
|
|
loadWorkflowStateForExecutionMock.mockResolvedValue({
|
|
blocks: {
|
|
stale: {
|
|
id: 'stale',
|
|
type: 'function',
|
|
name: 'Stale',
|
|
position: { x: 0, y: 0 },
|
|
subBlocks: {},
|
|
outputs: {},
|
|
enabled: true,
|
|
},
|
|
},
|
|
edges: [],
|
|
loops: {},
|
|
parallels: {},
|
|
})
|
|
})
|
|
|
|
it('uses the executed workflow state override for execution snapshots', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'manual', 'req-1')
|
|
const executedWorkflowState = {
|
|
blocks: {
|
|
loop: {
|
|
id: 'loop',
|
|
type: 'loop',
|
|
name: 'Loop',
|
|
position: { x: 0, y: 0 },
|
|
subBlocks: {},
|
|
outputs: {},
|
|
enabled: true,
|
|
},
|
|
parallel: {
|
|
id: 'parallel',
|
|
type: 'parallel',
|
|
name: 'Parallel',
|
|
position: { x: 100, y: 80 },
|
|
subBlocks: {},
|
|
outputs: {},
|
|
enabled: true,
|
|
data: { parentId: 'loop', extent: 'parent' as const },
|
|
},
|
|
},
|
|
edges: [],
|
|
loops: { loop: { id: 'loop', nodes: ['parallel'], iterations: 1, loopType: 'for' as const } },
|
|
parallels: { parallel: { id: 'parallel', nodes: [], count: 1 } },
|
|
}
|
|
|
|
await session.start({
|
|
workspaceId: 'workspace-1',
|
|
workflowState: executedWorkflowState,
|
|
})
|
|
|
|
expect(loadWorkflowStateForExecutionMock).not.toHaveBeenCalled()
|
|
expect(startWorkflowExecutionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
workflowState: executedWorkflowState,
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('LoggingSession completion retries', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
dbMocks.selectLimit.mockResolvedValue([{ executionData: {} }])
|
|
dbMocks.updateWhere.mockResolvedValue(undefined)
|
|
dbMocks.execute.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('keeps completion best-effort when a later error completion retries after full completion and fallback both fail', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('success finalize failed'))
|
|
.mockRejectedValueOnce(new Error('cost only failed'))
|
|
.mockResolvedValueOnce({})
|
|
|
|
await expect(session.safeComplete({ finalOutput: { ok: true } })).resolves.toBeUndefined()
|
|
|
|
await expect(
|
|
session.safeCompleteWithError({
|
|
error: { message: 'fallback error finalize' },
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledTimes(3)
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
|
|
it('reuses the settled completion promise for repeated completion attempts', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('success finalize failed'))
|
|
.mockRejectedValueOnce(new Error('cost only failed'))
|
|
|
|
await expect(session.safeComplete({ finalOutput: { ok: true } })).resolves.toBeUndefined()
|
|
await expect(session.safeComplete({ finalOutput: { ok: true } })).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('starts a new error completion attempt after a non-error completion and fallback both fail', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-3', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('success finalize failed'))
|
|
.mockRejectedValueOnce(new Error('cost only failed'))
|
|
.mockResolvedValueOnce({})
|
|
|
|
await expect(session.safeComplete({ finalOutput: { ok: true } })).resolves.toBeUndefined()
|
|
|
|
await expect(
|
|
session.safeCompleteWithError({
|
|
error: { message: 'late error finalize' },
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledTimes(3)
|
|
expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
executionId: 'execution-3',
|
|
finalOutput: { error: 'late error finalize' },
|
|
})
|
|
)
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
|
|
it('preserves successful final output during fallback completion', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-5', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('success finalize failed'))
|
|
.mockResolvedValueOnce({})
|
|
|
|
await expect(
|
|
session.safeComplete({ finalOutput: { ok: true, stage: 'done' } })
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
executionId: 'execution-5',
|
|
finalOutput: { ok: true, stage: 'done' },
|
|
finalizationPath: 'fallback_completed',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('derives fallback cost from trace spans when the primary completion fails', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-6', 'api', 'req-1') as any
|
|
|
|
// Resume-accumulation is retired: the cost-only fallback now derives its
|
|
// cost summary from the in-memory trace spans (billing itself reconciles
|
|
// from the usage_log ledger in recordExecutionUsage). The primary complete()
|
|
// path consumes one calculateCostSummary call before it fails, so queue the
|
|
// same value twice (primary attempt + fallback).
|
|
const spanCostSummary = {
|
|
totalCost: 12,
|
|
totalInputCost: 5,
|
|
totalOutputCost: 7,
|
|
totalTokens: 24,
|
|
totalPromptTokens: 11,
|
|
totalCompletionTokens: 13,
|
|
baseExecutionCharge: 0,
|
|
models: {},
|
|
charges: {},
|
|
}
|
|
vi.mocked(calculateCostSummary)
|
|
.mockReturnValueOnce(spanCostSummary)
|
|
.mockReturnValueOnce(spanCostSummary)
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('success finalize failed'))
|
|
.mockResolvedValueOnce({})
|
|
|
|
const traceSpans = [
|
|
{
|
|
id: 'span-1',
|
|
name: 'Block A',
|
|
type: 'tool',
|
|
duration: 25,
|
|
startTime: '2026-03-13T10:00:00.000Z',
|
|
endTime: '2026-03-13T10:00:00.025Z',
|
|
status: 'success',
|
|
},
|
|
] as any
|
|
|
|
await expect(
|
|
session.safeComplete({ finalOutput: { ok: true }, traceSpans })
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(calculateCostSummary).toHaveBeenLastCalledWith(traceSpans)
|
|
expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
executionId: 'execution-6',
|
|
finalizationPath: 'fallback_completed',
|
|
costSummary: expect.objectContaining({
|
|
totalCost: 12,
|
|
totalInputCost: 5,
|
|
totalOutputCost: 7,
|
|
totalTokens: 24,
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('persists failed error semantics when completeWithError receives non-error trace spans', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-4', 'api', 'req-1')
|
|
const traceSpans = [
|
|
{
|
|
id: 'span-1',
|
|
name: 'Block A',
|
|
type: 'tool',
|
|
duration: 25,
|
|
startTime: '2026-03-13T10:00:00.000Z',
|
|
endTime: '2026-03-13T10:00:00.025Z',
|
|
status: 'success',
|
|
},
|
|
]
|
|
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
|
|
await expect(
|
|
session.safeCompleteWithError({
|
|
error: { message: 'persist me as failed' },
|
|
traceSpans,
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
executionId: 'execution-4',
|
|
finalOutput: { error: 'persist me as failed' },
|
|
traceSpans,
|
|
level: 'error',
|
|
status: 'failed',
|
|
finalizationPath: 'force_failed',
|
|
completionFailure: 'persist me as failed',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('marks paused completions as completed and deduplicates later attempts', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
|
|
await expect(
|
|
session.safeCompleteWithPause({
|
|
endedAt: new Date().toISOString(),
|
|
totalDurationMs: 10,
|
|
traceSpans: [],
|
|
workflowInput: { hello: 'world' },
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(session.hasCompleted()).toBe(true)
|
|
|
|
await expect(
|
|
session.safeCompleteWithError({
|
|
error: { message: 'should be ignored' },
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('falls back to cost-only logging when paused completion fails', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-2', 'api', 'req-1')
|
|
|
|
completeWorkflowExecutionMock
|
|
.mockRejectedValueOnce(new Error('pause finalize failed'))
|
|
.mockResolvedValueOnce({})
|
|
|
|
await expect(
|
|
session.safeCompleteWithPause({
|
|
endedAt: new Date().toISOString(),
|
|
totalDurationMs: 10,
|
|
traceSpans: [],
|
|
workflowInput: { hello: 'world' },
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(session.hasCompleted()).toBe(true)
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('persists last started block independently from cost accumulation', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z')
|
|
|
|
expect(dbMocks.select).not.toHaveBeenCalled()
|
|
expect(dbMocks.execute).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('enforces started marker monotonicity in the database write path', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z')
|
|
|
|
expect(dbMocks.sql).toHaveBeenCalled()
|
|
expect(dbMocks.execute).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('allows same-millisecond started markers to replace the prior marker', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z')
|
|
|
|
const queryCall = dbMocks.sql.mock.calls.at(-1)
|
|
expect(queryCall).toBeDefined()
|
|
|
|
const [query] = queryCall!
|
|
expect(Array.from(query).join(' ')).toContain('<=')
|
|
})
|
|
|
|
it('persists last completed block for zero-cost outputs', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.onBlockComplete('block-2', 'Transform', 'function', {
|
|
endedAt: '2025-01-01T00:00:01.000Z',
|
|
output: { value: true },
|
|
})
|
|
|
|
expect(dbMocks.select).not.toHaveBeenCalled()
|
|
expect(dbMocks.execute).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('allows same-millisecond completed markers to replace the prior marker', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.onBlockComplete('block-2', 'Transform', 'function', {
|
|
endedAt: '2025-01-01T00:00:01.000Z',
|
|
output: { value: true },
|
|
})
|
|
|
|
const queryCall = dbMocks.sql.mock.calls.at(-1)
|
|
expect(queryCall).toBeDefined()
|
|
|
|
const [query] = queryCall!
|
|
expect(Array.from(query).join(' ')).toContain('<=')
|
|
})
|
|
|
|
it('drains pending lifecycle writes before terminal completion', async () => {
|
|
let releasePersist: (() => void) | undefined
|
|
const persistPromise = new Promise<void>((resolve) => {
|
|
releasePersist = resolve
|
|
})
|
|
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') as any
|
|
session.persistLastStartedBlock = vi.fn(() => persistPromise)
|
|
session.complete = vi.fn().mockResolvedValue(undefined)
|
|
|
|
const startPromise = session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z')
|
|
const completionPromise = session.safeComplete({ finalOutput: { ok: true } })
|
|
|
|
await Promise.resolve()
|
|
|
|
expect(session.complete).not.toHaveBeenCalled()
|
|
|
|
releasePersist?.()
|
|
|
|
await startPromise
|
|
await completionPromise
|
|
|
|
expect(session.persistLastStartedBlock).toHaveBeenCalledTimes(1)
|
|
expect(session.complete).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('drains fire-and-forget block-complete marker writes before terminal completion', async () => {
|
|
let releasePersist: (() => void) | undefined
|
|
const persistPromise = new Promise<void>((resolve) => {
|
|
releasePersist = resolve
|
|
})
|
|
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') as any
|
|
session.persistLastCompletedBlock = vi.fn(() => persistPromise)
|
|
session.complete = vi.fn().mockResolvedValue(undefined)
|
|
|
|
// onBlockComplete is now marker-only; its marker write is fire-and-forget
|
|
// but tracked, so terminal completion must drain it first.
|
|
void session.onBlockComplete('block-2', 'Transform', 'function', {
|
|
endedAt: '2025-01-01T00:00:01.000Z',
|
|
output: { value: true },
|
|
})
|
|
|
|
const completionPromise = session.safeComplete({ finalOutput: { ok: true } })
|
|
|
|
await Promise.resolve()
|
|
|
|
expect(session.complete).not.toHaveBeenCalled()
|
|
|
|
releasePersist?.()
|
|
|
|
await completionPromise
|
|
|
|
expect(session.persistLastCompletedBlock).toHaveBeenCalledTimes(1)
|
|
expect(session.complete).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps draining when new progress writes arrive during drain', async () => {
|
|
let releaseFirst: (() => void) | undefined
|
|
let releaseSecond: (() => void) | undefined
|
|
const firstPromise = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve
|
|
})
|
|
const secondPromise = new Promise<void>((resolve) => {
|
|
releaseSecond = resolve
|
|
})
|
|
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') as any
|
|
|
|
void session.trackProgressWrite(firstPromise)
|
|
|
|
const drainPromise = session.drainPendingProgressWrites()
|
|
|
|
await Promise.resolve()
|
|
|
|
void session.trackProgressWrite(secondPromise)
|
|
releaseFirst?.()
|
|
|
|
await Promise.resolve()
|
|
|
|
let drained = false
|
|
void drainPromise.then(() => {
|
|
drained = true
|
|
})
|
|
|
|
await Promise.resolve()
|
|
expect(drained).toBe(false)
|
|
|
|
releaseSecond?.()
|
|
await drainPromise
|
|
|
|
expect(session.pendingProgressWrites.size).toBe(0)
|
|
})
|
|
|
|
it('marks pause completion as terminal and prevents duplicate pause finalization', async () => {
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') as any
|
|
session.completeExecutionWithFinalization = vi.fn().mockResolvedValue(undefined)
|
|
|
|
await session.completeWithPause({ workflowInput: { ok: true } })
|
|
await session.completeWithPause({ workflowInput: { ok: true } })
|
|
|
|
expect(session.completeExecutionWithFinalization).toHaveBeenCalledTimes(1)
|
|
expect(session.completed).toBe(true)
|
|
expect(session.completing).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('completeWithError cancelled-status guard', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
dbMocks.updateWhere.mockResolvedValue(undefined)
|
|
dbMocks.execute.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('skips writing failed and marks session complete when DB status is already cancelled', async () => {
|
|
dbMocks.selectLimit.mockResolvedValue([{ status: 'cancelled' }])
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.safeCompleteWithError({ error: { message: 'block errored mid-cancel' } })
|
|
|
|
expect(completeWorkflowExecutionMock).not.toHaveBeenCalled()
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
|
|
it('writes failed when DB status is running (no cancel in flight)', async () => {
|
|
dbMocks.selectLimit.mockResolvedValue([{ status: 'running' }])
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.safeCompleteWithError({ error: { message: 'genuine block failure' } })
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'failed' })
|
|
)
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
|
|
it('writes failed when no execution log exists yet', async () => {
|
|
dbMocks.selectLimit.mockResolvedValue([])
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.safeCompleteWithError({ error: { message: 'pre-log error' } })
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'failed' })
|
|
)
|
|
})
|
|
|
|
it('deduplicates all subsequent completion attempts after guard early-return', async () => {
|
|
dbMocks.selectLimit.mockResolvedValue([{ status: 'cancelled' }])
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.safeCompleteWithError({ error: { message: 'error 1' } })
|
|
await session.safeCompleteWithError({ error: { message: 'error 2' } })
|
|
await session.safeComplete({ finalOutput: { ok: true } })
|
|
|
|
expect(completeWorkflowExecutionMock).not.toHaveBeenCalled()
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
|
|
it('falls through to cost-only fallback when the DB check itself throws', async () => {
|
|
dbMocks.selectLimit.mockRejectedValueOnce(new Error('DB connection lost'))
|
|
completeWorkflowExecutionMock.mockResolvedValue({})
|
|
const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1')
|
|
|
|
await session.safeCompleteWithError({ error: { message: 'block failed' } })
|
|
|
|
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ finalizationPath: 'force_failed' })
|
|
)
|
|
expect(session.hasCompleted()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('LoggingSession.markExecutionAsFailed workflowId scoping', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
dbMocks.updateWhere.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('scopes UPDATE by both executionId and workflowId', async () => {
|
|
await LoggingSession.markExecutionAsFailed('exec-1', undefined, undefined, 'wf-1')
|
|
|
|
expect(dbMocks.update).toHaveBeenCalledTimes(1)
|
|
expect(dbMocks.updateSet).toHaveBeenCalledTimes(1)
|
|
expect(dbMocks.updateWhere).toHaveBeenCalledTimes(1)
|
|
|
|
const whereArgs = dbMocks.updateWhere.mock.calls[0]
|
|
expect(whereArgs).toBeDefined()
|
|
})
|
|
|
|
it('instance markAsFailed forwards workflowId to the static method', async () => {
|
|
const updateWhereSpy = dbMocks.updateWhere
|
|
dbMocks.selectLimit.mockResolvedValue([{ executionData: {} }])
|
|
|
|
const session = new LoggingSession('wf-42', 'exec-42', 'api', 'req-1')
|
|
await session.markAsFailed('something went wrong')
|
|
|
|
expect(updateWhereSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('uses the provided errorMessage in the SQL set', async () => {
|
|
const sqlMock = dbMocks.sql
|
|
await LoggingSession.markExecutionAsFailed('exec-2', 'custom error', undefined, 'wf-2')
|
|
|
|
expect(sqlMock).toHaveBeenCalled()
|
|
const lastCall = sqlMock.mock.calls.at(-1)!
|
|
const [strings, ...values] = lastCall
|
|
const combined = String(Array.from(strings)).toLowerCase() + values.join(' ').toLowerCase()
|
|
expect(combined).toContain('force_failed')
|
|
})
|
|
|
|
it('clears Redis markers when marking failed (terminal boundary outside completeWorkflowExecution)', async () => {
|
|
await LoggingSession.markExecutionAsFailed('exec-3', 'boom', undefined, 'wf-3')
|
|
expect(clearProgressMarkersMock).toHaveBeenCalledWith('exec-3')
|
|
})
|
|
|
|
it('folds live Redis markers into the row before clearing on force-fail', async () => {
|
|
getProgressMarkersMock.mockResolvedValueOnce({
|
|
lastStartedBlock: { blockId: 'b1', blockName: 'Fetch', blockType: 'api', startedAt: 't1' },
|
|
lastCompletedBlock: {
|
|
blockId: 'b1',
|
|
blockName: 'Fetch',
|
|
blockType: 'api',
|
|
endedAt: 't2',
|
|
success: false,
|
|
},
|
|
})
|
|
|
|
await LoggingSession.markExecutionAsFailed('exec-9', 'boom', undefined, 'wf-9')
|
|
|
|
const folded = dbMocks.sql.mock.calls
|
|
.map((c) => String(Array.from(c[0] as TemplateStringsArray)))
|
|
.join(' ')
|
|
expect(folded).toContain('lastStartedBlock')
|
|
expect(folded).toContain('lastCompletedBlock')
|
|
expect(clearProgressMarkersMock).toHaveBeenCalledWith('exec-9')
|
|
})
|
|
|
|
it('does not clear markers when the Redis read fails (avoids wiping the only copy)', async () => {
|
|
getProgressMarkersMock.mockResolvedValueOnce(null)
|
|
await LoggingSession.markExecutionAsFailed('exec-readfail', 'boom', undefined, 'wf-x')
|
|
expect(clearProgressMarkersMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('LoggingSession progress-marker write path', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
startWorkflowExecutionMock.mockResolvedValue({})
|
|
loadWorkflowStateForExecutionMock.mockResolvedValue({
|
|
blocks: {},
|
|
edges: [],
|
|
loops: {},
|
|
parallels: {},
|
|
})
|
|
dbMocks.execute.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('writes markers to Redis (not the row) when Redis accepts the write', async () => {
|
|
setLastStartedBlockMock.mockResolvedValue(true)
|
|
setLastCompletedBlockMock.mockResolvedValue(true)
|
|
const session = new LoggingSession('wf-1', 'exec-redis', 'manual', 'req-1')
|
|
await session.start({ workspaceId: 'ws-1' })
|
|
|
|
await session.onBlockStart('b1', 'Fetch', 'api', '2026-06-27T10:00:00.000Z')
|
|
await session.onBlockComplete('b1', 'Fetch', 'api', { endedAt: '2026-06-27T10:00:01.000Z' })
|
|
|
|
expect(setLastStartedBlockMock).toHaveBeenCalledWith(
|
|
'exec-redis',
|
|
expect.objectContaining({ blockId: 'b1', startedAt: '2026-06-27T10:00:00.000Z' })
|
|
)
|
|
expect(setLastCompletedBlockMock).toHaveBeenCalledWith(
|
|
'exec-redis',
|
|
expect.objectContaining({ blockId: 'b1', success: true })
|
|
)
|
|
expect(dbMocks.execute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the SQL UPDATE when the Redis write fails', async () => {
|
|
setLastStartedBlockMock.mockResolvedValue(false)
|
|
const session = new LoggingSession('wf-1', 'exec-redis-down', 'manual', 'req-1')
|
|
await session.start({ workspaceId: 'ws-1' })
|
|
|
|
await session.onBlockStart('b1', 'Fetch', 'api', '2026-06-27T10:00:00.000Z')
|
|
|
|
expect(setLastStartedBlockMock).toHaveBeenCalled()
|
|
expect(dbMocks.execute).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|