Files
simstudioai--sim/apps/sim/lib/logs/execution/logging-session.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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)
})
})