chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+795
View File
@@ -0,0 +1,795 @@
import { envFlagsMock } from '@sim/testing'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { ExecutionLogger } from '@/lib/logs/execution/logger'
const dbSelectMock = vi.hoisted(() => vi.fn())
const dbExecuteMock = vi.hoisted(() => vi.fn())
const txUpdateMock = vi.hoisted(() =>
vi.fn(() => ({ set: () => ({ where: () => Promise.resolve() }) }))
)
vi.mock('@sim/db', () => {
// The reconcile runs inside db.transaction with an advisory lock. The tx
// shares dbSelectMock so the existing call-order seeding (call 1 = workflow
// row via .limit, call 2 = already-billed via .groupBy) still applies;
// tx.execute (set_config + pg_advisory_xact_lock) is a no-op; tx.update backs
// the exact cost_total refine.
const tx = {
select: dbSelectMock,
insert: vi.fn(),
update: txUpdateMock,
execute: dbExecuteMock,
}
return {
db: {
select: dbSelectMock,
insert: vi.fn(),
update: vi.fn(),
execute: dbExecuteMock,
transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise<unknown>) => cb(tx)),
},
}
})
// Mock billing modules
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: vi.fn(() => Promise.resolve(null)),
}))
vi.mock('@/lib/billing/core/usage', () => ({
checkUsageStatus: vi.fn(() =>
Promise.resolve({
usageData: { limit: 100, percentUsed: 50, currentUsage: 50 },
})
),
getOrgUsageLimit: vi.fn(() => Promise.resolve({ limit: 1000 })),
maybeSendUsageThresholdEmail: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
recordUsage: vi.fn(() => Promise.resolve()),
stableEventKey: vi.fn((parts: Record<string, unknown>) => JSON.stringify(parts)),
deriveBillingContext: vi.fn((userId: string) => ({
billingEntity: { type: 'user', id: userId },
billingPeriod: { start: new Date('2024-01-01'), end: new Date('2024-02-01') },
})),
}))
vi.mock('@/lib/billing/threshold-billing', () => ({
checkAndBillOverageThreshold: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
// Mock security module
vi.mock('@/lib/core/security/redaction', () => ({
redactApiKeys: vi.fn((data) => data),
}))
// Mock display filters
vi.mock('@/lib/core/utils/display-filters', () => ({
filterForDisplay: vi.fn((data) => data),
}))
// Mock workspace event emission
vi.mock('@/lib/workspace-events/emitter', () => ({
emitExecutionCompletedEvent: vi.fn(() => Promise.resolve()),
}))
// Mock snapshot service
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
snapshotService: {
createSnapshotWithDeduplication: vi.fn(() =>
Promise.resolve({
snapshot: {
id: 'snapshot-123',
workflowId: 'workflow-123',
stateHash: 'hash-123',
stateData: { blocks: {}, edges: [], loops: {}, parallels: {} },
createdAt: '2024-01-01T00:00:00.000Z',
},
isNew: true,
})
),
getSnapshot: vi.fn(() =>
Promise.resolve({
id: 'snapshot-123',
workflowId: 'workflow-123',
stateHash: 'hash-123',
stateData: { blocks: {}, edges: [], loops: {}, parallels: {} },
createdAt: '2024-01-01T00:00:00.000Z',
})
),
},
}))
describe('ExecutionLogger', () => {
let logger: ExecutionLogger
beforeEach(() => {
logger = new ExecutionLogger()
vi.clearAllMocks()
})
describe('class instantiation', () => {
test('should create logger instance', () => {
expect(logger).toBeDefined()
expect(logger).toBeInstanceOf(ExecutionLogger)
})
})
describe('interface implementation', () => {
test('should have startWorkflowExecution method', () => {
expect(typeof logger.startWorkflowExecution).toBe('function')
})
test('should have completeWorkflowExecution method', () => {
expect(typeof logger.completeWorkflowExecution).toBe('function')
})
test('should have getWorkflowExecution method', () => {
expect(typeof logger.getWorkflowExecution).toBe('function')
})
test('preserves correlation and diagnostics when execution completes', () => {
const loggerInstance = new ExecutionLogger() as any
const completedData = loggerInstance.buildCompletedExecutionData({
existingExecutionData: {
environment: {
variables: {},
workflowId: 'workflow-123',
executionId: 'execution-123',
userId: 'user-123',
workspaceId: 'workspace-123',
},
trigger: {
type: 'webhook',
source: 'webhook',
timestamp: '2025-01-01T00:00:00.000Z',
data: {
correlation: {
executionId: 'execution-123',
requestId: 'req-1234',
source: 'webhook',
workflowId: 'workflow-123',
webhookId: 'webhook-123',
path: 'incoming/slack',
triggerType: 'webhook',
},
},
},
lastStartedBlock: {
blockId: 'block-start',
blockName: 'Start',
blockType: 'agent',
startedAt: '2025-01-01T00:00:00.000Z',
},
lastCompletedBlock: {
blockId: 'block-end',
blockName: 'Finish',
blockType: 'api',
endedAt: '2025-01-01T00:00:05.000Z',
success: true,
},
},
traceSpans: [],
finalOutput: { ok: true },
finalizationPath: 'completed',
completionFailure: 'fallback failure',
executionCost: {
tokens: { input: 0, output: 0, total: 0 },
models: {},
},
})
expect(completedData.environment?.workflowId).toBe('workflow-123')
expect(completedData.trigger?.data?.correlation).toEqual({
executionId: 'execution-123',
requestId: 'req-1234',
source: 'webhook',
workflowId: 'workflow-123',
webhookId: 'webhook-123',
path: 'incoming/slack',
triggerType: 'webhook',
})
expect(completedData.correlation).toEqual(completedData.trigger?.data?.correlation)
expect(completedData.finalOutput).toEqual({ ok: true })
expect(completedData.lastStartedBlock?.blockId).toBe('block-start')
expect(completedData.lastCompletedBlock?.blockId).toBe('block-end')
expect(completedData.finalizationPath).toBe('completed')
expect(completedData.completionFailure).toBe('fallback failure')
expect(completedData.hasTraceSpans).toBe(false)
expect(completedData.traceSpanCount).toBe(0)
})
test('summarizes oversized execution data before storage', () => {
const loggerInstance = new ExecutionLogger() as any
const largePayload = 'x'.repeat(1_100_000)
const executionState = {
blockStates: {
blockA: {
output: { data: largePayload },
executed: true,
executionTime: 10,
},
},
executedBlocks: ['blockA'],
blockLogs: [
{
blockId: 'blockA',
blockName: 'HTTP',
blockType: 'api',
startedAt: '2025-01-01T00:00:00.000Z',
endedAt: '2025-01-01T00:00:01.000Z',
durationMs: 1000,
success: true,
executionOrder: 1,
input: { url: 'https://example.com/image.jpg', data: largePayload },
output: { data: largePayload },
},
],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
const completedData = loggerInstance.buildCompletedExecutionData({
traceSpans: [
{
id: 'workflow-execution',
name: 'Workflow Execution',
type: 'workflow',
duration: 1000,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-01-01T00:00:01.000Z',
status: 'success',
children: [
{
id: 'blockA-1',
name: 'HTTP',
type: 'api',
duration: 1000,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-01-01T00:00:01.000Z',
status: 'success',
blockId: 'blockA',
executionOrder: 1,
input: { url: 'https://example.com/image.jpg', data: largePayload },
output: { data: largePayload },
},
],
},
],
finalOutput: { data: largePayload },
executionState,
finalizationPath: 'completed',
executionCost: {
tokens: { input: 0, output: 0, total: 0 },
models: {},
},
})
const compacted = loggerInstance.compactExecutionDataForStorage(
completedData,
'execution-oversized'
)
const storedBytes = Buffer.byteLength(JSON.stringify(compacted), 'utf8')
expect(storedBytes).toBeLessThanOrEqual(3 * 1024 * 1024)
expect(compacted.executionDataTruncated).toBe(true)
expect(compacted.executionState).toBeUndefined()
expect(compacted.executionStateSummary).toEqual({
executedBlockCount: 1,
blockLogCount: 1,
completedLoopCount: 0,
activeExecutionPathLength: 0,
pendingQueueLength: 0,
})
expect(compacted.traceSpans?.[0]?.children?.[0]?.input).toEqual({
_truncated: true,
reason: 'execution_data_size_limit',
originalBytes: expect.any(Number),
summary: 'object with 2 keys',
})
})
})
describe('file extraction', () => {
test('should extract files from trace spans with files property', () => {
const loggerInstance = new ExecutionLogger()
// Access the private method through the class prototype
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const traceSpans = [
{
id: 'span-1',
output: {
files: [
{
id: 'file-1',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
url: 'https://example.com/file.pdf',
key: 'uploads/file.pdf',
},
],
},
},
]
const files = extractFilesMethod(traceSpans, null, null)
expect(files).toHaveLength(1)
expect(files[0].name).toBe('test.pdf')
expect(files[0].id).toBe('file-1')
})
test('should extract files from attachments property', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const traceSpans = [
{
id: 'span-1',
output: {
attachments: [
{
id: 'attach-1',
name: 'attachment.docx',
size: 2048,
type: 'application/docx',
url: 'https://example.com/attach.docx',
key: 'attachments/attach.docx',
},
],
},
},
]
const files = extractFilesMethod(traceSpans, null, null)
expect(files).toHaveLength(1)
expect(files[0].name).toBe('attachment.docx')
})
test('should deduplicate files with same ID', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const duplicateFile = {
id: 'file-1',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
url: 'https://example.com/file.pdf',
key: 'uploads/file.pdf',
}
const traceSpans = [
{ id: 'span-1', output: { files: [duplicateFile] } },
{ id: 'span-2', output: { files: [duplicateFile] } },
]
const files = extractFilesMethod(traceSpans, null, null)
expect(files).toHaveLength(1)
})
test('should extract files from final output', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const finalOutput = {
files: [
{
id: 'output-file-1',
name: 'output.txt',
size: 512,
type: 'text/plain',
url: 'https://example.com/output.txt',
key: 'outputs/output.txt',
},
],
}
const files = extractFilesMethod([], finalOutput, null)
expect(files).toHaveLength(1)
expect(files[0].name).toBe('output.txt')
})
test('should extract files from workflow input', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const workflowInput = {
files: [
{
id: 'input-file-1',
name: 'input.csv',
size: 256,
type: 'text/csv',
url: 'https://example.com/input.csv',
key: 'inputs/input.csv',
},
],
}
const files = extractFilesMethod([], null, workflowInput)
expect(files).toHaveLength(1)
expect(files[0].name).toBe('input.csv')
})
test('should handle empty inputs', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const files = extractFilesMethod(undefined, undefined, undefined)
expect(files).toHaveLength(0)
})
test('should handle deeply nested file objects', () => {
const loggerInstance = new ExecutionLogger()
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
loggerInstance
)
const traceSpans = [
{
id: 'span-1',
output: {
nested: {
deeply: {
files: [
{
id: 'nested-file-1',
name: 'nested.json',
size: 128,
type: 'application/json',
url: 'https://example.com/nested.json',
key: 'nested/file.json',
},
],
},
},
},
},
]
const files = extractFilesMethod(traceSpans, null, null)
expect(files).toHaveLength(1)
expect(files[0].name).toBe('nested.json')
})
})
})
describe('recordExecutionUsage boundary-delta reconciliation', () => {
let logger: any
beforeEach(() => {
logger = new ExecutionLogger() as any
vi.clearAllMocks()
})
const costSummary = (overrides: Record<string, unknown> = {}) => ({
totalCost: 0,
totalInputCost: 0,
totalOutputCost: 0,
totalTokens: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
baseExecutionCharge: 0.005,
models: {},
charges: {},
...overrides,
})
// db.select() is called twice in recordExecutionUsage: first the workflow row
// (terminated by .limit), then the already-billed usage_log rows (terminated
// by .groupBy). Return each in order.
const mockDb = (billedRows: Array<Record<string, unknown>>) => {
let call = 0
dbSelectMock.mockImplementation(() => {
call += 1
const rows = call === 1 ? [{ id: 'workflow-1', workspaceId: 'ws-1' }] : billedRows
const chain: any = {
from: () => chain,
where: () => chain,
limit: () => Promise.resolve(rows),
groupBy: () => Promise.resolve(rows),
}
return chain
})
}
const run = (
summary: ReturnType<typeof costSummary>,
billedRows: Array<Record<string, unknown>>
) => {
mockDb(billedRows)
return logger.recordExecutionUsage('workflow-1', summary, 'api', 'exec-1', 'user-1')
}
const lastEntries = () => vi.mocked(recordUsage).mock.calls[0][0].entries
test('fresh completion records all targets (base fee + model) and returns the increment', async () => {
const recorded = await run(
costSummary({
models: {
'gpt-4o': {
total: 1,
input: 0.6,
output: 0.4,
tokens: { input: 10, output: 5, total: 15 },
},
},
}),
[]
)
expect(recordUsage).toHaveBeenCalledTimes(1)
expect(lastEntries()).toEqual([
expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }),
expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 1 }),
])
// Returns the amount recorded at this boundary (drives threshold-email math).
expect(recorded).toBeCloseTo(1.005, 8)
// cost_total is refined to the exact ledger sum inside the locked tx.
expect(txUpdateMock).toHaveBeenCalledTimes(1)
})
test('resume records only the increment over what is already billed', async () => {
await run(
costSummary({
models: {
'gpt-4o': {
total: 3,
input: 1.8,
output: 1.2,
tokens: { input: 30, output: 15, total: 45 },
},
},
}),
[
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '1' },
]
)
expect(recordUsage).toHaveBeenCalledTimes(1)
const entries = lastEntries()
expect(entries).toHaveLength(1)
expect(entries[0]).toEqual(
expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 2 })
)
})
test('returns only the post-resume increment (not the cumulative total)', async () => {
const recorded = await run(
costSummary({
models: {
'gpt-4o': {
total: 3,
input: 0,
output: 0,
tokens: { input: 0, output: 0, total: 0 },
},
},
}),
[
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '1' },
]
)
// Cumulative is 3.005, but only the $2 increment was recorded here — the
// threshold-email math must not re-count the pre-pause $1.005.
expect(recorded).toBe(2)
})
test('forwards a pre-resolved billing context to recordUsage (skips re-lookup)', async () => {
mockDb([])
const billingContext = {
billingEntity: { type: 'user' as const, id: 'user-1' },
billingPeriod: { start: new Date('2026-01-01'), end: new Date('2026-02-01') },
}
await (logger as any).recordExecutionUsage(
'workflow-1',
costSummary({
models: {
'gpt-4o': {
total: 1,
input: 0,
output: 0,
tokens: { input: 0, output: 0, total: 0 },
},
},
}),
'api',
'exec-1',
'user-1',
billingContext
)
expect(vi.mocked(recordUsage).mock.calls[0][0]).toMatchObject({
billingEntity: { type: 'user', id: 'user-1' },
billingPeriod: billingContext.billingPeriod,
})
})
test('retry with everything already billed records nothing (idempotent)', async () => {
await run(
costSummary({
models: {
'gpt-4o': {
total: 1,
input: 0.6,
output: 0.4,
tokens: { input: 10, output: 5, total: 15 },
},
},
}),
[
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '1' },
]
)
expect(recordUsage).not.toHaveBeenCalled()
})
test('BYOK run records only the base fee (no zero-cost model rows)', async () => {
await run(costSummary({ models: {}, charges: {} }), [])
expect(recordUsage).toHaveBeenCalledTimes(1)
expect(lastEntries()).toEqual([
expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }),
])
})
test('standalone hosted-tool charge reconciles as a tool row', async () => {
await run(costSummary({ charges: { 'Exa Search': { total: 0.02 } } }), [
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
])
expect(lastEntries()).toEqual([
expect.objectContaining({ category: 'tool', description: 'Exa Search', cost: 0.02 }),
])
})
test('two boundaries (pause then resume) bill the full run exactly once', async () => {
const model = (total: number) => ({
'gpt-4o': {
input: total * 0.6,
output: total * 0.4,
total,
tokens: { input: 10, output: 5, total: 15 },
},
})
// Boundary 1 (pause): nothing billed yet, partial cost.
await run(costSummary({ models: model(1) }), [])
const firstEntries = vi.mocked(recordUsage).mock.calls[0][0].entries
// Feed boundary 1's rows back as already-billed for boundary 2.
const billedAfterFirst = firstEntries.map((e: any) => ({
category: e.category,
description: e.description,
cost: String(e.cost),
}))
// Boundary 2 (resume terminal): same model, higher cumulative cost.
await run(costSummary({ models: model(3) }), billedAfterFirst)
const secondEntries = vi.mocked(recordUsage).mock.calls[1][0].entries
const ledgerTotal = [...firstEntries, ...secondEntries].reduce(
(sum: number, e: any) => sum + e.cost,
0
)
expect(ledgerTotal).toBeCloseTo(3.005, 8) // base 0.005 once + gpt-4o 3 total
// Base fee billed once (boundary 1 only); model increment only at boundary 2.
expect(firstEntries.some((e: any) => e.category === 'fixed')).toBe(true)
expect(secondEntries.some((e: any) => e.category === 'fixed')).toBe(false)
expect(secondEntries).toEqual([
expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 2 }),
])
})
test('eventKey is scoped by billedBefore so cross-boundary increments do not collide', async () => {
const model = (total: number) => ({
'gpt-4o': { input: 0, output: 0, total, tokens: { input: 0, output: 0, total: 0 } },
})
await run(costSummary({ models: model(1) }), [])
const key0 = vi
.mocked(recordUsage)
.mock.calls[0][0].entries.find((e: any) => e.category === 'model')?.eventKey
await run(costSummary({ models: model(3) }), [
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '1' },
])
const key1 = vi
.mocked(recordUsage)
.mock.calls[1][0].entries.find((e: any) => e.category === 'model')?.eventKey
expect(key0).toContain('"billedBefore":"0.00000000"')
expect(key1).toContain('"billedBefore":"1.00000000"')
expect(key0).not.toEqual(key1)
})
test('a decreased cumulative cost (negative delta) records nothing for that line', async () => {
await run(
costSummary({
models: {
'gpt-4o': { input: 0, output: 0, total: 3, tokens: { input: 0, output: 0, total: 0 } },
},
}),
[
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '5' },
]
)
expect(recordUsage).not.toHaveBeenCalled()
})
test('a model introduced only post-resume is billed in full; the already-billed model is skipped', async () => {
await run(
costSummary({
models: {
'gpt-4o': { input: 0, output: 0, total: 1, tokens: { input: 0, output: 0, total: 0 } },
'claude-3': { input: 0, output: 0, total: 2, tokens: { input: 0, output: 0, total: 0 } },
},
}),
[
{ category: 'fixed', description: 'execution_fee', cost: '0.005' },
{ category: 'model', description: 'gpt-4o', cost: '1' },
]
)
expect(lastEntries()).toEqual([
expect.objectContaining({ category: 'model', description: 'claude-3', cost: 2 }),
])
})
test('zero-cost models and charges (BYOK) are filtered out, leaving only the base fee', async () => {
await run(
costSummary({
models: {
'gpt-4o': { input: 0, output: 0, total: 0, tokens: { input: 0, output: 0, total: 0 } },
},
charges: { Exa: { total: 0 } },
}),
[]
)
expect(lastEntries()).toEqual([
expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }),
])
})
test('reconciles inside a transaction holding a per-execution advisory lock', async () => {
await run(
costSummary({
models: {
'gpt-4o': { input: 0, output: 0, total: 1, tokens: { input: 0, output: 0, total: 0 } },
},
}),
[]
)
// set_config('lock_timeout') + pg_advisory_xact_lock both run on the tx.
expect(dbExecuteMock).toHaveBeenCalledTimes(2)
expect(recordUsage).toHaveBeenCalledTimes(1)
// The ledger INSERT participates in the locked transaction.
expect(vi.mocked(recordUsage).mock.calls[0][0]).toHaveProperty('tx')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
import { workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
calculateCostSummary,
createEnvironmentObject,
createTriggerObject,
} from '@/lib/logs/execution/logging-factory'
/** Mock the billing constants */
vi.mock('@/lib/billing/constants', () => ({
BASE_EXECUTION_CHARGE: 0.005,
}))
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
beforeEach(() => {
workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
})
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
})
})
describe('createTriggerObject', () => {
test('should create a trigger object with basic type', () => {
const trigger = createTriggerObject('manual')
expect(trigger.type).toBe('manual')
expect(trigger.source).toBe('manual')
expect(trigger.timestamp).toBeDefined()
expect(new Date(trigger.timestamp).getTime()).not.toBeNaN()
})
test('should create a trigger object for api type', () => {
const trigger = createTriggerObject('api')
expect(trigger.type).toBe('api')
expect(trigger.source).toBe('api')
})
test('should create a trigger object for webhook type', () => {
const trigger = createTriggerObject('webhook')
expect(trigger.type).toBe('webhook')
expect(trigger.source).toBe('webhook')
})
test('should create a trigger object for schedule type', () => {
const trigger = createTriggerObject('schedule')
expect(trigger.type).toBe('schedule')
expect(trigger.source).toBe('schedule')
})
test('should create a trigger object for chat type', () => {
const trigger = createTriggerObject('chat')
expect(trigger.type).toBe('chat')
expect(trigger.source).toBe('chat')
})
test('should include additional data when provided', () => {
const additionalData = {
requestId: 'req-123',
headers: { 'x-custom': 'value' },
}
const trigger = createTriggerObject('api', additionalData)
expect(trigger.type).toBe('api')
expect(trigger.data).toEqual(additionalData)
})
test('should not include data property when additionalData is undefined', () => {
const trigger = createTriggerObject('manual')
expect(trigger.data).toBeUndefined()
})
test('should not include data property when additionalData is empty', () => {
const trigger = createTriggerObject('manual', undefined)
expect(trigger.data).toBeUndefined()
})
})
describe('createEnvironmentObject', () => {
test('should create an environment object with all fields', () => {
const env = createEnvironmentObject(
'workflow-123',
'execution-456',
'user-789',
'workspace-abc',
{ API_KEY: 'secret', DEBUG: 'true' }
)
expect(env.workflowId).toBe('workflow-123')
expect(env.executionId).toBe('execution-456')
expect(env.userId).toBe('user-789')
expect(env.workspaceId).toBe('workspace-abc')
expect(env.variables).toEqual({ API_KEY: 'secret', DEBUG: 'true' })
})
test('should use empty string for optional userId', () => {
const env = createEnvironmentObject('workflow-123', 'execution-456')
expect(env.userId).toBe('')
})
test('should use empty string for optional workspaceId', () => {
const env = createEnvironmentObject('workflow-123', 'execution-456', 'user-789')
expect(env.workspaceId).toBe('')
})
test('should use empty object for optional variables', () => {
const env = createEnvironmentObject(
'workflow-123',
'execution-456',
'user-789',
'workspace-abc'
)
expect(env.variables).toEqual({})
})
test('should handle all optional parameters as undefined', () => {
const env = createEnvironmentObject('workflow-123', 'execution-456')
expect(env.workflowId).toBe('workflow-123')
expect(env.executionId).toBe('execution-456')
expect(env.userId).toBe('')
expect(env.workspaceId).toBe('')
expect(env.variables).toEqual({})
})
})
describe('calculateCostSummary', () => {
const BASE_EXECUTION_CHARGE = 0.005
test('should return base execution charge for empty trace spans', () => {
const result = calculateCostSummary([])
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
expect(result.baseExecutionCharge).toBe(BASE_EXECUTION_CHARGE)
expect(result.totalInputCost).toBe(0)
expect(result.totalOutputCost).toBe(0)
expect(result.totalTokens).toBe(0)
expect(result.totalPromptTokens).toBe(0)
expect(result.totalCompletionTokens).toBe(0)
expect(result.models).toEqual({})
})
test('should return base execution charge for undefined trace spans', () => {
const result = calculateCostSummary(undefined as any)
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
})
test('should calculate cost from single span with cost data', () => {
const traceSpans = [
{
id: 'span-1',
name: 'Agent Block',
type: 'agent',
model: 'gpt-4',
cost: {
input: 0.01,
output: 0.02,
total: 0.03,
},
tokens: {
input: 100,
output: 200,
total: 300,
},
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
expect(result.totalInputCost).toBe(0.01)
expect(result.totalOutputCost).toBe(0.02)
expect(result.totalTokens).toBe(300)
expect(result.totalPromptTokens).toBe(100)
expect(result.totalCompletionTokens).toBe(200)
expect(result.models['gpt-4']).toBeDefined()
expect(result.models['gpt-4'].total).toBe(0.03)
})
test('should calculate cost from multiple spans', () => {
const traceSpans = [
{
id: 'span-1',
name: 'Agent Block 1',
type: 'agent',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
{
id: 'span-2',
name: 'Agent Block 2',
type: 'agent',
model: 'gpt-3.5-turbo',
cost: { input: 0.001, output: 0.002, total: 0.003 },
tokens: { input: 50, output: 100, total: 150 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.033 + BASE_EXECUTION_CHARGE)
expect(result.totalInputCost).toBe(0.011)
expect(result.totalOutputCost).toBe(0.022)
expect(result.totalTokens).toBe(450)
expect(result.models['gpt-4']).toBeDefined()
expect(result.models['gpt-3.5-turbo']).toBeDefined()
})
test('should accumulate costs for same model across spans', () => {
const traceSpans = [
{
id: 'span-1',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
{
id: 'span-2',
model: 'gpt-4',
cost: { input: 0.02, output: 0.04, total: 0.06 },
tokens: { input: 200, output: 400, total: 600 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.models['gpt-4'].input).toBe(0.03)
expect(result.models['gpt-4'].output).toBe(0.06)
expect(result.models['gpt-4'].total).toBe(0.09)
expect(result.models['gpt-4'].tokens.input).toBe(300)
expect(result.models['gpt-4'].tokens.output).toBe(600)
expect(result.models['gpt-4'].tokens.total).toBe(900)
})
test('should handle nested children with cost data', () => {
const traceSpans = [
{
id: 'parent-span',
name: 'Parent',
type: 'workflow',
children: [
{
id: 'child-span-1',
model: 'claude-3',
cost: { input: 0.005, output: 0.01, total: 0.015 },
tokens: { input: 50, output: 100, total: 150 },
},
{
id: 'child-span-2',
model: 'claude-3',
cost: { input: 0.005, output: 0.01, total: 0.015 },
tokens: { input: 50, output: 100, total: 150 },
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
expect(result.models['claude-3']).toBeDefined()
expect(result.models['claude-3'].total).toBe(0.03)
})
test('should handle deeply nested children', () => {
const traceSpans = [
{
id: 'level-1',
children: [
{
id: 'level-2',
children: [
{
id: 'level-3',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
],
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.models['gpt-4']).toBeDefined()
})
test('should handle prompt/completion token aliases', () => {
const traceSpans = [
{
id: 'span-1',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { prompt: 100, completion: 200, total: 300 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalPromptTokens).toBe(100)
expect(result.totalCompletionTokens).toBe(200)
})
test('should skip spans without cost data', () => {
const traceSpans = [
{
id: 'span-without-cost',
name: 'Text Block',
type: 'text',
},
{
id: 'span-with-cost',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
]
const result = calculateCostSummary(traceSpans)
expect(Object.keys(result.models)).toHaveLength(1)
})
test('should handle spans without model specified', () => {
const traceSpans = [
{
id: 'span-1',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
// No model specified
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
// Should not add to models if model is not specified
expect(Object.keys(result.models)).toHaveLength(0)
})
test('should handle missing token fields gracefully', () => {
const traceSpans = [
{
id: 'span-1',
model: 'gpt-4',
cost: { input: 0.01, output: 0.02, total: 0.03 },
// tokens field is missing
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalTokens).toBe(0)
expect(result.totalPromptTokens).toBe(0)
expect(result.totalCompletionTokens).toBe(0)
})
test('should handle partial cost fields', () => {
const traceSpans = [
{
id: 'span-1',
model: 'gpt-4',
cost: { total: 0.03 }, // Only total specified
tokens: { total: 300 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
expect(result.totalInputCost).toBe(0)
expect(result.totalOutputCost).toBe(0)
})
test('BYOK regression: parent block cost is authoritative; model children are not double-counted', () => {
// Reproduces the BYOK billing leak: provider sets parent agent span's
// block-level cost to zero (BYOK suppression), but trace enrichers still
// wrote gross hosted cost into time-segment children. Before the fix this
// test would expect 0.03; after the fix the parent's zero is authoritative.
const traceSpans = [
{
id: 'agent-span',
type: 'agent',
model: 'claude-opus-4-6',
cost: { input: 0, output: 0, total: 0 },
tokens: { input: 68057, output: 1548, total: 69605 },
children: [
{
id: 'agent-span-segment-0',
type: 'model',
model: 'claude-opus-4-6',
cost: { input: 0.340285, output: 0.0387, total: 0.378985 },
tokens: { input: 68057, output: 1548, total: 69605 },
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
// Model is still tracked for token-usage display, but cost must be zero.
expect(result.models['claude-opus-4-6'].total).toBe(0)
expect(result.models['claude-opus-4-6'].input).toBe(0)
expect(result.models['claude-opus-4-6'].output).toBe(0)
expect(result.models['claude-opus-4-6'].tokens.input).toBe(68057)
expect(result.models['claude-opus-4-6'].tokens.output).toBe(1548)
})
test('non-BYOK still aggregates parent block cost correctly with model children present', () => {
// Same shape as the BYOK case but the parent carries the gross cost
// (typical hosted-key path). The parent's cost is counted once; model
// children are skipped to avoid double-counting.
const traceSpans = [
{
id: 'agent-span',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 1000, output: 2000, total: 3000 },
children: [
{
id: 'agent-span-segment-0',
type: 'model',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 1000, output: 2000, total: 3000 },
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
expect(result.models['gpt-4o'].total).toBe(0.03)
})
test('preserves parent toolCost while skipping model breakdown children', () => {
const traceSpans = [
{
id: 'agent-span',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, toolCost: 0.015, total: 0.045 },
tokens: { input: 1000, output: 2000, total: 3000 },
children: [
{
id: 'agent-span-model-segment',
type: 'model',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 1000, output: 2000, total: 3000 },
},
{
id: 'agent-span-tool-segment',
type: 'tool',
name: 'firecrawl_scrape',
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.totalCost).toBe(0.045 + BASE_EXECUTION_CHARGE)
expect(result.models['gpt-4o'].total).toBe(0.045)
expect(result.models['gpt-4o'].toolCost).toBe(0.015)
})
test('records a standalone non-model billable span as a charge (closes the tool gap)', () => {
const traceSpans = [
{
id: 'exa-block',
name: 'Exa Search',
type: 'tool',
cost: { input: 0, output: 0, total: 0.01 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.charges['Exa Search']).toBeDefined()
expect(result.charges['Exa Search'].total).toBe(0.01)
expect(Object.keys(result.models)).toHaveLength(0)
// Ledger partition reconciles with the run total.
const ledgerSum =
result.baseExecutionCharge +
Object.values(result.models).reduce((s, m) => s + m.total, 0) +
Object.values(result.charges).reduce((s, c) => s + c.total, 0)
expect(ledgerSum).toBeCloseTo(result.totalCost, 10)
})
test('does not double-count: agent-embedded tool stays in the model row, not charges', () => {
const traceSpans = [
{
id: 'agent-span',
name: 'Agent',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.045, toolCost: 0.015 },
tokens: { input: 1000, output: 2000, total: 3000 },
},
]
const result = calculateCostSummary(traceSpans)
expect(Object.keys(result.charges)).toHaveLength(0)
expect(result.models['gpt-4o'].total).toBe(0.045)
expect(result.models['gpt-4o'].toolCost).toBe(0.015)
})
test('mixed model + standalone tool run reconciles to total', () => {
const traceSpans = [
{
id: 'agent',
name: 'Agent',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
{
id: 'exa',
name: 'Exa Search',
type: 'tool',
cost: { input: 0, output: 0, total: 0.01 },
},
]
const result = calculateCostSummary(traceSpans)
expect(result.models['gpt-4o'].total).toBe(0.03)
expect(result.charges['Exa Search'].total).toBe(0.01)
const ledgerSum =
result.baseExecutionCharge +
Object.values(result.models).reduce((s, m) => s + m.total, 0) +
Object.values(result.charges).reduce((s, c) => s + c.total, 0)
expect(ledgerSum).toBeCloseTo(result.totalCost, 10)
})
test('BYOK tool (no cost generated upstream) produces no charge row', () => {
const traceSpans = [
{
id: 'exa-byok',
name: 'Exa Search',
type: 'tool',
cost: { input: 0, output: 0, total: 0 },
},
]
const result = calculateCostSummary(traceSpans)
expect(Object.keys(result.charges)).toHaveLength(0)
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
})
test('does not double-count the synthetic workflow root (aggregate cost over leaves)', () => {
// buildTraceSpans wraps every run in a synthetic { type: 'workflow' } root
// whose cost.total is the SUM of its leaves. Counting that root in addition
// to the leaves double-charges the run — the root must be a pass-through.
const traceSpans = [
{
id: 'workflow-execution',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.04 }, // == agent(0.03) + exa(0.01)
children: [
{
id: 'agent-1',
name: 'Agent',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
{
id: 'exa-1',
name: 'Exa Search',
type: 'tool',
cost: { input: 0, output: 0, total: 0.01 },
},
],
},
]
const result = calculateCostSummary(traceSpans)
// The 0.04 root aggregate is NOT added on top of its leaves.
expect(result.charges['Workflow Execution']).toBeUndefined()
expect(result.models['gpt-4o'].total).toBe(0.03)
expect(result.charges['Exa Search'].total).toBe(0.01)
expect(result.totalCost).toBeCloseTo(0.04 + BASE_EXECUTION_CHARGE, 10)
const ledgerSum =
result.baseExecutionCharge +
Object.values(result.models).reduce((s, m) => s + m.total, 0) +
Object.values(result.charges).reduce((s, c) => s + c.total, 0)
expect(ledgerSum).toBeCloseTo(result.totalCost, 10)
})
test('does not double-count nested sub-workflow roots', () => {
// A sub-workflow call nests another synthetic { type: 'workflow' } root
// (captureChildWorkflowLogs runs buildTraceSpans on the child). Both the
// outer root and the inner sub-workflow root carry aggregate costs; only the
// leaf agent inside should be billed.
const traceSpans = [
{
id: 'workflow-execution',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.03 },
children: [
{
id: 'subworkflow-root',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.03 },
children: [
{
id: 'child-agent',
name: 'Agent',
type: 'agent',
model: 'gpt-4o',
cost: { input: 0.01, output: 0.02, total: 0.03 },
tokens: { input: 100, output: 200, total: 300 },
},
],
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.charges['Workflow Execution']).toBeUndefined()
expect(result.models['gpt-4o'].total).toBe(0.03)
expect(result.totalCost).toBeCloseTo(0.03 + BASE_EXECUTION_CHARGE, 10)
})
test('does not double-count deeply nested (3-level) sub-workflow roots', () => {
// A → B → C: each level is its own synthetic { type: 'workflow' } root with
// an aggregate cost. Only the leaf agents at the bottom must be billed, once.
const leafAgent = (id: string, model: string, total: number) => ({
id,
name: 'Agent',
type: 'agent',
model,
cost: { input: total / 3, output: (total * 2) / 3, total },
tokens: { input: 100, output: 200, total: 300 },
})
const traceSpans = [
{
id: 'root',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.06 }, // aggregate of everything below
children: [
leafAgent('parent-agent', 'gpt-4o', 0.02),
{
id: 'sub-a-root',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.04 },
children: [
leafAgent('a-agent', 'gpt-4o', 0.01),
{
id: 'sub-b-root',
name: 'Workflow Execution',
type: 'workflow',
cost: { total: 0.03 },
children: [leafAgent('b-agent', 'claude-sonnet-4-6', 0.03)],
},
],
},
],
},
]
const result = calculateCostSummary(traceSpans)
expect(result.charges['Workflow Execution']).toBeUndefined()
// gpt-4o appears at two levels (0.02 + 0.01) and merges by model name.
expect(result.models['gpt-4o'].total).toBeCloseTo(0.03, 10)
expect(result.models['claude-sonnet-4-6'].total).toBeCloseTo(0.03, 10)
expect(result.totalCost).toBeCloseTo(0.06 + BASE_EXECUTION_CHARGE, 10)
const ledgerSum =
result.baseExecutionCharge +
Object.values(result.models).reduce((s, m) => s + m.total, 0) +
Object.values(result.charges).reduce((s, c) => s + c.total, 0)
expect(ledgerSum).toBeCloseTo(result.totalCost, 10)
})
})
@@ -0,0 +1,267 @@
import { db, workflow } from '@sim/db'
import { eq } from 'drizzle-orm'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import type {
ExecutionEnvironment,
ExecutionTrigger,
TraceSpan,
WorkflowState,
} from '@/lib/logs/types'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
export function createTriggerObject(
type: ExecutionTrigger['type'],
additionalData?: Record<string, unknown>
): ExecutionTrigger {
return {
type,
source: type,
timestamp: new Date().toISOString(),
...(additionalData && { data: additionalData }),
}
}
export function createEnvironmentObject(
workflowId: string,
executionId: string,
userId?: string,
workspaceId?: string,
variables?: Record<string, string>
): ExecutionEnvironment {
return {
variables: variables || {},
workflowId,
executionId,
userId: userId || '',
workspaceId: workspaceId || '',
}
}
export async function loadWorkflowStateForExecution(workflowId: string): Promise<WorkflowState> {
const [normalizedData, workflowRecord] = await Promise.all([
loadWorkflowFromNormalizedTables(workflowId),
db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
.then((rows) => rows[0]),
])
if (!normalizedData) {
throw new Error(
`Workflow ${workflowId} has no normalized data available. Ensure the workflow is properly saved to normalized tables.`
)
}
return {
blocks: normalizedData.blocks || {},
edges: normalizedData.edges || [],
loops: normalizedData.loops || {},
parallels: normalizedData.parallels || {},
variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined,
}
}
/**
* Load deployed workflow state for logging purposes.
* This fetches the active deployment state, ensuring logs capture
* the exact state that was executed (not the live editor state).
*/
export async function loadDeployedWorkflowStateForLogging(
workflowId: string
): Promise<WorkflowState> {
const deployedData = await loadDeployedWorkflowState(workflowId)
return {
blocks: deployedData.blocks || {},
edges: deployedData.edges || [],
loops: deployedData.loops || {},
parallels: deployedData.parallels || {},
variables: deployedData.variables as WorkflowState['variables'],
}
}
type CostTraceSpan = Pick<TraceSpan, 'cost' | 'model' | 'tokens'> & {
type?: TraceSpan['type']
name?: TraceSpan['name']
children?: CostTraceSpan[]
}
export interface CostSummaryModel {
input: number
output: number
total: number
toolCost?: number
tokens: { input: number; output: number; total: number }
}
/**
* Non-model billable charge (e.g. a standalone hosted-key tool block such as
* Exa/Tavily/falai run outside an agent). These spans contribute to the run's
* total cost but carry no `model`, so they live here rather than in `models`.
* Summed per span name so the ledger has one row per integration.
*/
export interface CostSummaryCharge {
total: number
}
export interface CostSummary {
totalCost: number
totalInputCost: number
totalOutputCost: number
totalTokens: number
totalPromptTokens: number
totalCompletionTokens: number
baseExecutionCharge: number
models: Record<string, CostSummaryModel>
/** Non-model billable charges keyed by span name (tool/integration costs). */
charges: Record<string, CostSummaryCharge>
}
type BillableTraceSpan = CostTraceSpan & { cost: NonNullable<TraceSpan['cost']> }
function hasBillableCost(span: CostTraceSpan): span is BillableTraceSpan {
return span.cost !== undefined
}
function isModelBreakdownSpan(span: CostTraceSpan): boolean {
return span.type === 'model'
}
export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): CostSummary {
if (!traceSpans || traceSpans.length === 0) {
return {
totalCost: BASE_EXECUTION_CHARGE,
totalInputCost: 0,
totalOutputCost: 0,
totalTokens: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
baseExecutionCharge: BASE_EXECUTION_CHARGE,
models: {},
charges: {},
}
}
/**
* Collects spans that contribute to the execution's billable cost.
*
* Rule: when a span has its own `cost` AND has child model segments, the
* parent's block-level cost is authoritative — skip the model children to
* avoid double-counting. The parent cost is set by the provider response
* (and is correctly zeroed by `executeProviderRequest` for BYOK calls);
* model children only carry per-segment cost from the trace enrichers,
* which is unaware of BYOK status. Non-model children are still visited
* so standalone nested costs remain billable.
*
* Spans without their own `cost` (e.g. parent workflow spans for
* subworkflow blocks) still recurse so nested billable spans are counted.
*/
const collectCostSpans = (spans: CostTraceSpan[]): BillableTraceSpan[] => {
const costSpans: BillableTraceSpan[] = []
for (const span of spans) {
// `workflow`-typed spans are aggregate containers, not billable units: the
// synthetic "Workflow Execution" root (added to every run by
// buildTraceSpans) and any nested sub-workflow root carry a `cost.total`
// equal to the SUM of their descendants. Counting that aggregate in
// addition to the descendants double-charges the run, so treat these as
// pass-through: never count their own cost, always recurse into all
// children where the real billable leaves (agents, tools) live.
const isAggregateContainer = span.type === 'workflow'
const hasOwnCost = hasBillableCost(span)
const countOwnCost = hasOwnCost && !isAggregateContainer
if (countOwnCost) {
costSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
if (countOwnCost) {
// Authoritative leaf (e.g. an agent block whose block-level cost is set
// by the provider response and already accounts for its model
// segments): only recurse into non-model children to find further
// standalone billable units, skipping the model-breakdown duplicates.
const nonModelChildren = span.children.filter((child) => !isModelBreakdownSpan(child))
costSpans.push(...collectCostSpans(nonModelChildren))
} else {
// Container (workflow / sub-workflow root) or a no-cost parent: recurse
// into everything so nested billable leaves are counted exactly once.
costSpans.push(...collectCostSpans(span.children))
}
}
}
return costSpans
}
const costSpans = collectCostSpans(traceSpans)
let totalCost = 0
let totalInputCost = 0
let totalOutputCost = 0
let totalTokens = 0
let totalPromptTokens = 0
let totalCompletionTokens = 0
const models: Record<string, CostSummaryModel> = {}
const charges: Record<string, CostSummaryCharge> = {}
for (const span of costSpans) {
totalCost += span.cost.total || 0
totalInputCost += span.cost.input || 0
totalOutputCost += span.cost.output || 0
totalTokens += span.tokens?.total || 0
totalPromptTokens += span.tokens?.input ?? span.tokens?.prompt ?? 0
totalCompletionTokens += span.tokens?.output ?? span.tokens?.completion ?? 0
if (span.model) {
const model = span.model
if (!models[model]) {
models[model] = {
input: 0,
output: 0,
total: 0,
tokens: { input: 0, output: 0, total: 0 },
}
}
models[model].input += span.cost.input || 0
models[model].output += span.cost.output || 0
models[model].total += span.cost.total || 0
models[model].tokens.input += span.tokens?.input ?? span.tokens?.prompt ?? 0
models[model].tokens.output += span.tokens?.output ?? span.tokens?.completion ?? 0
models[model].tokens.total += span.tokens?.total || 0
if (span.cost.toolCost) {
models[model].toolCost = (models[model].toolCost || 0) + span.cost.toolCost
}
} else if ((span.cost.total || 0) > 0) {
// Non-model billable span (e.g. a standalone hosted-key tool block).
// These previously contributed to the run total but were never itemized
// in the ledger (the "standalone tool gap"). Key by span name so each
// integration gets a single, reconciling charge row.
const description = span.name || span.type || 'tool'
if (!charges[description]) {
charges[description] = { total: 0 }
}
charges[description].total += span.cost.total || 0
}
}
totalCost += BASE_EXECUTION_CHARGE
return {
totalCost,
totalInputCost,
totalOutputCost,
totalTokens,
totalPromptTokens,
totalCompletionTokens,
baseExecutionCharge: BASE_EXECUTION_CHARGE,
models,
charges,
}
}
@@ -0,0 +1,747 @@
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)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaterializeRef, mockCompact, mockMaterializeManifest, mockMaskBatch } = vi.hoisted(
() => ({
mockMaterializeRef: vi.fn(),
mockCompact: vi.fn(),
mockMaterializeManifest: vi.fn(),
mockMaskBatch: vi.fn(),
})
)
vi.mock('@/lib/execution/payloads/store', () => ({
materializeLargeValueRef: mockMaterializeRef,
}))
vi.mock('@/lib/execution/payloads/serializer', () => ({
compactExecutionPayload: mockCompact,
}))
vi.mock('@/lib/execution/payloads/large-array-manifest', () => ({
materializeLargeArrayManifest: mockMaterializeManifest,
}))
vi.mock('@/lib/execution/payloads/large-array-manifest-metadata', () => ({
isLargeArrayManifest: () => false,
}))
vi.mock('@/lib/guardrails/mask-client', () => ({
maskPIIBatchViaHttp: mockMaskBatch,
}))
import {
redactLargeValueRefs,
redactLargeValueRefsInValue,
} from '@/lib/logs/execution/pii-large-values'
import { PiiRedactionError } from '@/lib/logs/execution/pii-redaction'
const REF = {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdef123456',
kind: 'object',
size: 9_000_000,
} as const
const STORE = { workspaceId: 'ws-1', workflowId: 'wf-1', executionId: 'ex-1', userId: 'u-1' }
describe('redactLargeValueRefs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
// compact echoes its input so we can assert the masked content is what's re-stored.
mockCompact.mockImplementation(async (value: unknown) => value)
})
it('hydrates, masks, and re-stores a large-value ref (content preserved, PII masked)', async () => {
mockMaterializeRef.mockResolvedValue({ note: 'contact bob', id: 42 })
const result = await redactLargeValueRefs(
{ finalOutput: REF },
{ entityTypes: ['PERSON'], language: 'en', store: STORE }
)
expect(mockMaterializeRef).toHaveBeenCalledWith(
REF,
expect.objectContaining({ executionId: 'ex-1', trackReference: false })
)
expect(result.finalOutput).toEqual({ note: 'MASKED(contact bob)', id: 42 })
expect(mockCompact).toHaveBeenCalledTimes(1)
})
it('falls back to the marker when a ref cannot be materialized', async () => {
mockMaterializeRef.mockResolvedValue(undefined)
const result = await redactLargeValueRefs(
{ finalOutput: REF },
{ entityTypes: [], language: 'en', store: STORE }
)
expect(result.finalOutput).toBe('[REDACTION_FAILED]')
expect(mockCompact).not.toHaveBeenCalled()
})
it('falls back to the marker when re-store throws (never leaks)', async () => {
mockMaterializeRef.mockResolvedValue({ note: 'secret@x.com' })
mockCompact.mockRejectedValueOnce(new Error('s3 down'))
const result = await redactLargeValueRefs(
{ finalOutput: REF },
{ entityTypes: [], language: 'en', store: STORE }
)
expect(result.finalOutput).toBe('[REDACTION_FAILED]')
})
it('hydrates+masks refs across multiple payload keys (parallel, cross-key)', async () => {
const refA = { ...REF, id: 'lv_aaaaaaaaaaaa' }
const refB = { ...REF, id: 'lv_bbbbbbbbbbbb' }
mockMaterializeRef.mockImplementation(async (ref: { id: string }) =>
ref.id === 'lv_aaaaaaaaaaaa' ? { note: 'call bob' } : { note: 'email amy' }
)
const result = await redactLargeValueRefs(
{ finalOutput: refA, traceSpans: [{ output: refB }] },
{ entityTypes: [], language: 'en', store: STORE }
)
expect(result.finalOutput).toEqual({ note: 'MASKED(call bob)' })
expect((result.traceSpans as any[])[0].output).toEqual({ note: 'MASKED(email amy)' })
expect(mockMaterializeRef).toHaveBeenCalledTimes(2)
expect(mockCompact).toHaveBeenCalledTimes(2)
})
it('aborts (throws) when one of several refs fails in throw mode', async () => {
const refA = { ...REF, id: 'lv_aaaaaaaaaaaa' }
const refB = { ...REF, id: 'lv_bbbbbbbbbbbb' }
// refA materializes fine; refB can't — in throw mode the whole redaction must abort.
mockMaterializeRef.mockImplementation(async (ref: { id: string }) =>
ref.id === 'lv_aaaaaaaaaaaa' ? { note: 'ok' } : undefined
)
await expect(
redactLargeValueRefs(
{ finalOutput: refA, traceSpans: [{ output: refB }] },
{ entityTypes: [], language: 'en', store: STORE, onFailure: 'throw' }
)
).rejects.toBeInstanceOf(PiiRedactionError)
})
it('leaves payloads without refs untouched', async () => {
const payload = { finalOutput: { answer: 'world', count: 5 } }
const result = await redactLargeValueRefs(payload, {
entityTypes: [],
language: 'en',
store: STORE,
})
expect(result).toEqual(payload)
expect(mockMaterializeRef).not.toHaveBeenCalled()
})
})
describe('redactLargeValueRefsInValue (arbitrary blockStates)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
mockCompact.mockImplementation(async (value: unknown) => value)
})
it('hydrates + re-stores a ref nested in a non-RedactablePayload shape', async () => {
mockMaterializeRef.mockResolvedValue({ note: 'contact bob' })
const blockStates = { 'block-1': { output: REF }, 'block-2': { output: { plain: 'hi' } } }
const result = await redactLargeValueRefsInValue(blockStates, {
entityTypes: ['PERSON'],
language: 'en',
store: STORE,
})
expect((result as any)['block-1'].output).toEqual({ note: 'MASKED(contact bob)' })
expect((result as any)['block-2'].output).toEqual({ plain: 'hi' })
})
it('throws PiiRedactionError on failure when onFailure is throw (aborts resume, no marker)', async () => {
mockMaterializeRef.mockResolvedValue(undefined)
await expect(
redactLargeValueRefsInValue(
{ 'block-1': { output: REF } },
{ entityTypes: [], language: 'en', store: STORE, onFailure: 'throw' }
)
).rejects.toBeInstanceOf(PiiRedactionError)
})
it('rethrows a re-store failure as PiiRedactionError under throw mode', async () => {
mockMaterializeRef.mockResolvedValue({ note: 'secret@x.com' })
mockCompact.mockRejectedValueOnce(new Error('s3 down'))
await expect(
redactLargeValueRefsInValue(
{ 'block-1': { output: REF } },
{ entityTypes: [], language: 'en', store: STORE, onFailure: 'throw' }
)
).rejects.toBeInstanceOf(PiiRedactionError)
})
})
@@ -0,0 +1,240 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import type { LargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
import { materializeLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import type { LargeValueStoreContext } from '@/lib/execution/payloads/store'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import {
PiiRedactionError,
type PiiRedactionFailureMode,
REDACTION_FAILED_MARKER,
type RedactablePayload,
redactObjectStrings,
} from '@/lib/logs/execution/pii-redaction'
const logger = createLogger('PiiLargeValues')
export interface RedactLargeValueRefsOptions {
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
language: string
/** Storage scope for materializing and re-storing the masked values. */
store: LargeValueStoreContext
/**
* How to handle a ref that can't be materialized/masked/re-stored. Defaults to
* `'scrub'` (marker) — safe for the logs path. The execution-altering restore
* path passes `'throw'` so an unmaskable restored value aborts the run rather
* than feeding a marker (or leaking raw bytes) downstream.
*/
onFailure?: PiiRedactionFailureMode
}
/**
* Hydrate, mask, and re-store offloaded large values inside a log payload.
*
* The string redactor can't reach a value already offloaded to large-value
* storage (a >8MB ref) — its bytes live in object storage, not inline. This walks
* the payload and, for each ref / array manifest, materializes it, masks its
* content, and re-stores a fresh masked ref — so the log keeps the redacted
* content rather than losing the whole field. Any failure (materialization
* unavailable, missing storage scope, re-store error) falls back to
* {@link REDACTION_FAILED_MARKER} so raw PII is never left behind.
*
* Traversal is SYNCHRONOUS (collect refs, then substitute) so a large ref-free
* payload costs only a cheap walk — never a promise-per-node. Only the handful of
* actual refs incur async hydrate → mask → re-store work, and those run in
* parallel with bounded concurrency ({@link REF_CONCURRENCY}).
*/
export async function redactLargeValueRefs(
payload: RedactablePayload,
options: RedactLargeValueRefsOptions
): Promise<RedactablePayload> {
// Collect refs across the WHOLE payload first (shared `seen`), so every ref is
// hydrated+masked+re-stored in one bounded-concurrency pass instead of one
// sequential pass per key. A ref shared across keys is walked/masked once.
const refs: object[] = []
const seen = new WeakSet<object>()
for (const key of Object.keys(payload) as (keyof RedactablePayload)[]) {
if (payload[key] !== undefined) collectRefs(payload[key], refs, seen)
}
if (refs.length === 0) return payload
const replacements = await resolveReplacements(refs, options)
const result: RedactablePayload = { ...payload }
for (const key of Object.keys(payload) as (keyof RedactablePayload)[]) {
if (payload[key] !== undefined) result[key] = substituteRefs(payload[key], replacements)
}
return result
}
/**
* Hydrate, mask, and re-store offloaded large values inside an arbitrary value
* (e.g. resumed snapshot `blockStates`) — the same walk as
* {@link redactLargeValueRefs}, but not bound to the {@link RedactablePayload}
* key set. Structure is preserved; only refs/manifests are replaced.
*/
export async function redactLargeValueRefsInValue<T>(
value: T,
options: RedactLargeValueRefsOptions
): Promise<T> {
return (await redactValueRefs(value, options)) as T
}
/** Sync-collect every ref/manifest in `value`, then async-replace each, then sync-substitute. */
async function redactValueRefs(
value: unknown,
options: RedactLargeValueRefsOptions
): Promise<unknown> {
const refs: object[] = []
collectRefs(value, refs, new WeakSet())
if (refs.length === 0) return value
const replacements = await resolveReplacements(refs, options)
return substituteRefs(value, replacements)
}
/**
* Max large-value refs hydrated → masked → re-stored in parallel per payload.
* Multiplies with the mask-batch chunk concurrency for total in-flight Presidio
* load, which the load-balanced fleet behind the internal ALB absorbs.
*/
const REF_CONCURRENCY = env.PII_REF_CONCURRENCY ?? 4
/**
* Dedupe the collected refs by identity, then replace each in parallel (bounded by
* {@link REF_CONCURRENCY}). `Map.set` is synchronous, so concurrent workers writing
* the shared map do not race.
*
* `mapWithConcurrency`'s `fn` must not reject (a rejection fails the pool
* non-deterministically), so the mapper is total: it catches per-ref errors and
* records the first one. In `onFailure: 'throw'` mode `replaceRef` throws, so after
* the pool drains we rethrow that first error — an unmaskable ref still aborts the
* run rather than passing through. In `'scrub'` mode `replaceRef` never throws.
*/
async function resolveReplacements(
refs: object[],
options: RedactLargeValueRefsOptions
): Promise<Map<object, unknown>> {
const unique = [...new Set(refs)]
const replacements = new Map<object, unknown>()
let firstError: unknown
await mapWithConcurrency(unique, REF_CONCURRENCY, async (ref) => {
try {
replacements.set(ref, await replaceRef(ref, options))
} catch (error) {
if (firstError === undefined) firstError = error
}
})
if (firstError !== undefined) throw firstError
return replacements
}
/** Depth-first sync walk collecting ref/manifest nodes (not recursing into them). */
function collectRefs(value: unknown, out: object[], seen: WeakSet<object>): void {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
out.push(value as object)
return
}
if (value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
if (Array.isArray(value)) {
for (const item of value) collectRefs(item, out, seen)
return
}
for (const v of Object.values(value)) collectRefs(v, out, seen)
}
/** Sync rebuild of `value` with each collected ref swapped for its replacement (by identity). */
function substituteRefs(value: unknown, replacements: Map<object, unknown>): unknown {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return replacements.has(value as object) ? replacements.get(value as object) : value
}
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) {
return value.map((item) => substituteRefs(item, replacements))
}
const out: Record<string, unknown> = {}
for (const [key, v] of Object.entries(value)) {
out[key] = substituteRefs(v, replacements)
}
return out
}
async function replaceRef(ref: object, options: RedactLargeValueRefsOptions): Promise<unknown> {
return isLargeValueRef(ref)
? redactRef(ref, options)
: redactManifest(ref as LargeArrayManifest, options)
}
/**
* Mask a materialized large value and re-offload it: handle any nested refs
* first, then mask inline strings, then re-store. `redactObjectStrings` skips
* refs, so the nested re-stored refs are left intact while their siblings mask.
*/
async function maskAndReStore(
value: unknown,
options: RedactLargeValueRefsOptions
): Promise<unknown> {
const nested = await redactValueRefs(value, options)
const masked = await redactObjectStrings(nested, {
entityTypes: options.entityTypes,
language: options.language,
onFailure: options.onFailure ?? 'scrub',
})
return compactExecutionPayload(masked, { ...options.store, requireDurable: true })
}
/** Rethrow (as {@link PiiRedactionError}) or scrub-to-marker, per `onFailure`. */
function onRefFailure(
error: unknown,
options: RedactLargeValueRefsOptions,
context: string
): never | string {
if ((options.onFailure ?? 'scrub') === 'throw') {
throw error instanceof PiiRedactionError
? error
: new PiiRedactionError(`${context}: ${getErrorMessage(error)}`)
}
logger.error(`${context}; scrubbing`, { error: getErrorMessage(error) })
return REDACTION_FAILED_MARKER
}
async function redactRef(
ref: LargeValueRef,
options: RedactLargeValueRefsOptions
): Promise<unknown> {
try {
const materialized = await materializeLargeValueRef(ref, {
...options.store,
trackReference: false,
})
if (materialized === undefined) {
return onRefFailure(
new Error('large value could not be materialized'),
options,
'Failed to redact large value ref'
)
}
return await maskAndReStore(materialized, options)
} catch (error) {
return onRefFailure(error, options, 'Failed to redact large value ref')
}
}
async function redactManifest(
manifest: LargeArrayManifest,
options: RedactLargeValueRefsOptions
): Promise<unknown> {
try {
const materialized = await materializeLargeArrayManifest(manifest, { ...options.store })
return await maskAndReStore(materialized, options)
} catch (error) {
return onRefFailure(error, options, 'Failed to redact large array manifest')
}
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaskPIIBatch } = vi.hoisted(() => ({
mockMaskPIIBatch: vi.fn(),
}))
vi.mock('@/lib/guardrails/mask-client', () => ({
maskPIIBatchViaHttp: mockMaskPIIBatch,
}))
import {
PiiRedactionError,
REDACTION_FAILED_MARKER,
redactObjectStrings,
redactPIIFromExecution,
} from '@/lib/logs/execution/pii-redaction'
describe('redactPIIFromExecution', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: echo each input uppercased so we can assert substitution by position.
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('collects and masks string leaves recursively, preserving structure', async () => {
const payload = {
traceSpans: [
{
blockId: 'b1',
status: 'success',
input: { email: 'a@b.com' },
output: { text: 'hello' },
children: [{ blockId: 'c1', output: { nested: 'deep' } }],
},
],
finalOutput: { answer: 'world' },
workflowInput: 'start',
}
const result = await redactPIIFromExecution(payload, { entityTypes: ['EMAIL_ADDRESS'] })
const span = (result.traceSpans as any[])[0]
expect(span.blockId).toBe('b1')
expect(span.status).toBe('success')
expect(span.input.email).toBe('MASKED(a@b.com)')
expect(span.output.text).toBe('MASKED(hello)')
expect(span.children[0].output.nested).toBe('MASKED(deep)')
expect((result.finalOutput as any).answer).toBe('MASKED(world)')
expect(result.workflowInput).toBe('MASKED(start)')
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
expect(mockMaskPIIBatch.mock.calls[0][0]).toEqual([
'a@b.com',
'hello',
'deep',
'world',
'start',
])
})
it('does not mutate the original payload', async () => {
const payload = { finalOutput: { answer: 'world' } }
await redactPIIFromExecution(payload, { entityTypes: [] })
expect(payload.finalOutput.answer).toBe('world')
})
it('scrubs all eligible strings when masking throws (no leak)', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
const payload = {
traceSpans: [{ output: { text: 'secret@x.com' } }],
finalOutput: 'another secret',
}
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect((result.traceSpans as any[])[0].output.text).toBe(REDACTION_FAILED_MARKER)
expect(result.finalOutput).toBe(REDACTION_FAILED_MARKER)
})
it('masks large strings too (never left unredacted)', async () => {
const big = 'x'.repeat(200 * 1024)
const payload = { finalOutput: { big, small: 'pii' } }
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect((result.finalOutput as any).big).toBe(`MASKED(${big})`)
expect((result.finalOutput as any).small).toBe('MASKED(pii)')
expect(mockMaskPIIBatch.mock.calls[0][0]).toEqual([big, 'pii'])
})
it('masks span error/errorMessage and top-level error, trigger, executionState, environment', async () => {
const payload = {
traceSpans: [{ blockId: 'b1', error: 'failed for bob@x.com', errorMessage: 'bad input z' }],
error: 'run failed: a@b.com',
completionFailure: 'cancelled by c@d.com',
trigger: { type: 'webhook', data: { from: 'caller@x.com' } },
executionState: { status: 'completed', note: 'state for e@f.com' },
environment: { variables: { CONTACT: 'admin@x.com' } },
correlation: { source: 'corr@x.com' },
}
const result = await redactPIIFromExecution(payload, { entityTypes: ['EMAIL_ADDRESS'] })
const span = (result.traceSpans as any[])[0]
expect(span.blockId).toBe('b1')
expect(span.error).toBe('MASKED(failed for bob@x.com)')
expect(span.errorMessage).toBe('MASKED(bad input z)')
expect(result.error).toBe('MASKED(run failed: a@b.com)')
expect(result.completionFailure).toBe('MASKED(cancelled by c@d.com)')
expect((result.trigger as any).type).toBe('MASKED(webhook)')
expect((result.trigger as any).data.from).toBe('MASKED(caller@x.com)')
expect((result.executionState as any).note).toBe('MASKED(state for e@f.com)')
expect((result.environment as any).variables.CONTACT).toBe('MASKED(admin@x.com)')
expect((result.correlation as any).source).toBe('MASKED(corr@x.com)')
})
it('returns payload unchanged when there is nothing to mask', async () => {
const payload = { traceSpans: [{ blockId: 'b1', count: 5 }] }
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect(result).toBe(payload)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})
})
describe('redactObjectStrings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('masks every string leaf and preserves structure', async () => {
const value = { name: 'bob', nested: { email: 'a@b.com' }, list: ['x', 1, true] }
const result = await redactObjectStrings(value, { entityTypes: ['PERSON'] })
expect(result).toEqual({
name: 'MASKED(bob)',
nested: { email: 'MASKED(a@b.com)' },
list: ['MASKED(x)', 1, true],
})
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
})
it('leaves non-string and empty values untouched', async () => {
const value = { count: 5, flag: false, empty: '', nullish: null }
const result = await redactObjectStrings(value, { entityTypes: [] })
expect(result).toEqual(value)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})
it('throws PiiRedactionError on masking failure when onFailure is throw', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
await expect(
redactObjectStrings({ text: 'a@b.com' }, { entityTypes: [], onFailure: 'throw' })
).rejects.toBeInstanceOf(PiiRedactionError)
})
it('masks large payloads (no size ceiling) rather than scrubbing them', async () => {
const big = 'x'.repeat(17 * 1024 * 1024)
const result = (await redactObjectStrings({ big }, { entityTypes: [] })) as { big: string }
expect(result.big).toBe(`MASKED(${big})`)
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
})
it('scrubs (does not throw) by default on failure', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
const result = await redactObjectStrings({ text: 'a@b.com' }, { entityTypes: [] })
expect(result).toEqual({ text: REDACTION_FAILED_MARKER })
})
})
describe('transformStrings (via redactObjectStrings) leaves large-value refs intact', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('does not recurse into / corrupt a large-value ref while masking siblings', async () => {
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdef123456',
kind: 'object',
size: 9_000_000,
}
const result = (await redactObjectStrings(
{ name: 'bob', big: ref },
{ entityTypes: ['PERSON'] }
)) as any
expect(result.name).toBe('MASKED(bob)')
// The ref is left byte-for-byte intact (its key/id are not masked).
expect(result.big).toEqual(ref)
})
})
@@ -0,0 +1,243 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
const logger = createLogger('PiiRedaction')
/** Replaces text we could not safely mask, so PII is never persisted on failure. */
export const REDACTION_FAILED_MARKER = '[REDACTION_FAILED]'
/**
* How to handle a masking failure (Presidio error or over-ceiling payload):
* - `'scrub'` (default): replace eligible strings with {@link REDACTION_FAILED_MARKER}.
* Safe for the log stage — execution already succeeded.
* - `'throw'`: throw {@link PiiRedactionError}. Used for the execution-altering
* stages (input/block outputs) where a marker would corrupt computed data and
* fail-open would leak — so the run aborts instead.
*/
export type PiiRedactionFailureMode = 'scrub' | 'throw'
export interface PiiRedactionOptions {
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
language?: string
/** Failure handling. Defaults to `'scrub'`. */
onFailure?: PiiRedactionFailureMode
}
/** Thrown when in-flight redaction (`onFailure: 'throw'`) cannot mask safely. */
export class PiiRedactionError extends Error {
constructor(message: string) {
super(message)
this.name = 'PiiRedactionError'
}
}
export interface RedactablePayload {
traceSpans?: unknown
finalOutput?: unknown
workflowInput?: unknown
error?: unknown
completionFailure?: unknown
trigger?: unknown
executionState?: unknown
environment?: unknown
correlation?: unknown
}
/** Keys of {@link RedactablePayload} processed by the redactor, in order. */
const REDACTABLE_KEYS: (keyof RedactablePayload)[] = [
'traceSpans',
'finalOutput',
'workflowInput',
'error',
'completionFailure',
'trigger',
'executionState',
'environment',
'correlation',
]
/** Trace-span fields that carry runtime content (and therefore possible PII). */
const SPAN_CONTENT_FIELDS = [
'input',
'output',
'thinking',
'modelToolCalls',
'toolCalls',
'error',
'errorMessage',
] as const
function isEligibleString(value: string): boolean {
return value.length > 0
}
/**
* Rebuild `value` replacing every eligible string leaf with `handle(leaf)`.
* Used for both collection (handle records and returns the input) and
* substitution (handle returns the masked value), so traversal order and
* eligibility are guaranteed identical across the two passes.
*/
function transformStrings(value: unknown, handle: (s: string) => string): unknown {
if (typeof value === 'string') {
return isEligibleString(value) ? handle(value) : value
}
// Treat offloaded large-value refs as opaque: masking their internal `key`/`id`
// strings would corrupt the reference. Their content is handled separately
// (hydrate → mask → re-store) before this runs.
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
if (Array.isArray(value)) {
return value.map((item) => transformStrings(item, handle))
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [key, v] of Object.entries(value)) {
out[key] = transformStrings(v, handle)
}
return out
}
return value
}
/**
* Redact a trace span: only its content fields ({@link SPAN_CONTENT_FIELDS}) and
* nested `children` are walked, leaving structural metadata (blockId, name,
* status, timing) untouched so log correlation/display is preserved.
*/
function transformSpan(span: unknown, handle: (s: string) => string): unknown {
if (span === null || typeof span !== 'object' || Array.isArray(span)) {
return transformStrings(span, handle)
}
const source = span as Record<string, unknown>
const out: Record<string, unknown> = { ...source }
for (const field of SPAN_CONTENT_FIELDS) {
if (field in out) out[field] = transformStrings(out[field], handle)
}
if (Array.isArray(source.children)) {
out.children = source.children.map((child) => transformSpan(child, handle))
}
return out
}
function transformUnit(
key: keyof RedactablePayload,
value: unknown,
handle: (s: string) => string
): unknown {
if (key === 'traceSpans' && Array.isArray(value)) {
return value.map((span) => transformSpan(span, handle))
}
return transformStrings(value, handle)
}
/**
* Mask a batch of collected strings via Presidio. On a hard failure, either scrub
* to {@link REDACTION_FAILED_MARKER} or throw {@link PiiRedactionError}, per
* `options.onFailure`. Returns masked values aligned 1:1 with `collected`.
*
* There is no total-size ceiling — the batching layer chunks the request and
* fans out with bounded concurrency, so payloads of any size are masked properly
* rather than scrubbed. The per-chunk request timeout is the real backstop.
*/
async function maskCollected(
collected: string[],
options: PiiRedactionOptions
): Promise<{ masked: string[]; scrubbed: boolean }> {
const onFailure = options.onFailure ?? 'scrub'
const language = options.language ?? 'en'
try {
// Presidio runs only in the app container; the persist + execution paths also
// run in the trigger.dev runtime, so masking always goes over HTTP to the app.
const masked = await maskPIIBatchViaHttp(collected, options.entityTypes, language)
return { masked, scrubbed: false }
} catch (error) {
logger.error('PII masking failed', {
error: getErrorMessage(error),
stringCount: collected.length,
onFailure,
})
if (onFailure === 'throw') {
throw new PiiRedactionError(`PII redaction failed: ${getErrorMessage(error)}`)
}
return { masked: collected.map(() => REDACTION_FAILED_MARKER), scrubbed: true }
}
}
/**
* Mask every eligible string leaf of an arbitrary object in place-preserving
* fashion: collect all leaves in one deterministic pass, mask in a single batched
* Presidio call, then rebuild from the masked slice (the identical traversal
* order makes the two passes line up). Used for the execution-altering input and
* block-output stages, so it defaults callers toward `onFailure: 'throw'`.
*/
export async function redactObjectStrings<T>(value: T, options: PiiRedactionOptions): Promise<T> {
const collected: string[] = []
transformStrings(value, (s) => {
collected.push(s)
return s
})
if (collected.length === 0) return value
const { masked } = await maskCollected(collected, options)
let index = 0
return transformStrings(value, () => masked[index++]) as T
}
/**
* Mask PII across an execution's `traceSpans` / `finalOutput` / `workflowInput`.
*
* All eligible string leaves are collected in one deterministic pass and masked
* in a single batched (byte-chunked) Presidio call — so subprocess count scales
* with payload size, not block count. Each unit is then rebuilt independently
* from the masked slice, preserving the JSON structure (Presidio never sees the
* envelope). On a hard masking failure or when the payload exceeds the ceiling,
* eligible strings are replaced with {@link REDACTION_FAILED_MARKER} (the default
* `onFailure: 'scrub'`) rather than left unredacted — PII is never persisted on
* the failure path.
*/
export async function redactPIIFromExecution(
payload: RedactablePayload,
options: PiiRedactionOptions
): Promise<RedactablePayload> {
const startedAt = performance.now()
const units = REDACTABLE_KEYS.filter((key) => payload[key] !== undefined).map((key) => ({
key,
value: payload[key],
}))
const collected: string[] = []
let totalBytes = 0
for (const unit of units) {
transformUnit(unit.key, unit.value, (s) => {
collected.push(s)
totalBytes += Buffer.byteLength(s, 'utf8')
return s
})
}
if (collected.length === 0) return payload
const { masked, scrubbed } = await maskCollected(collected, options)
let index = 0
const result: RedactablePayload = { ...payload }
for (const unit of units) {
result[unit.key] = transformUnit(unit.key, unit.value, () => masked[index++])
}
logger.info('PII redaction completed', {
stringCount: collected.length,
totalBytes,
durationMs: Math.round(performance.now() - startedAt),
scrubbed,
})
return result
}
@@ -0,0 +1,203 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionLastCompletedBlock, ExecutionLastStartedBlock } from '@/lib/logs/types'
const { mockGetRedisClient, mockRedis } = vi.hoisted(() => {
const mockRedis = {
eval: vi.fn(),
hgetall: vi.fn(),
del: vi.fn(),
}
return { mockGetRedisClient: vi.fn<[], typeof mockRedis | null>(() => mockRedis), mockRedis }
})
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
vi.mock('@/lib/core/execution-limits', () => ({
getExecutionReservationTtlMs: () => 5_460_000,
}))
import {
clearProgressMarkers,
getProgressMarkers,
pickLatestCompletedMarker,
pickLatestStartedMarker,
setLastCompletedBlock,
setLastStartedBlock,
} from '@/lib/logs/execution/progress-markers'
const EXECUTION_ID = 'exec-1'
const KEY = `execution:progress:${EXECUTION_ID}`
const EXPECTED_TTL_MS = '5460000' // getExecutionReservationTtlMs() mock value
const startedMarker: ExecutionLastStartedBlock = {
blockId: 'b1',
blockName: 'Fetch',
blockType: 'api',
startedAt: '2026-06-27T10:00:00.000Z',
}
const completedMarker: ExecutionLastCompletedBlock = {
blockId: 'b1',
blockName: 'Fetch',
blockType: 'api',
endedAt: '2026-06-27T10:00:01.000Z',
success: true,
}
describe('progress-markers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.eval.mockResolvedValue(1)
mockRedis.hgetall.mockResolvedValue({})
mockRedis.del.mockResolvedValue(1)
})
describe('setLastStartedBlock', () => {
it('evals the monotonic-guard script with key, started field, timestamp, json, and TTL', async () => {
await setLastStartedBlock(EXECUTION_ID, startedMarker)
expect(mockRedis.eval).toHaveBeenCalledTimes(1)
const [, numKeys, key, field, timestampField, timestamp, json, ttl] =
mockRedis.eval.mock.calls[0]
expect(numKeys).toBe(1)
expect(key).toBe(KEY)
expect(field).toBe('started')
expect(timestampField).toBe('startedAt')
expect(timestamp).toBe(startedMarker.startedAt)
expect(JSON.parse(json as string)).toEqual(startedMarker)
expect(ttl).toBe(EXPECTED_TTL_MS)
})
it('returns true when the Redis write succeeds', async () => {
await expect(setLastStartedBlock(EXECUTION_ID, startedMarker)).resolves.toBe(true)
})
it('returns false (caller falls back to SQL) when the eval fails', async () => {
mockRedis.eval.mockRejectedValueOnce(new Error('redis down'))
await expect(setLastStartedBlock(EXECUTION_ID, startedMarker)).resolves.toBe(false)
})
it('returns false and no-ops when Redis is unavailable', async () => {
mockGetRedisClient.mockReturnValue(null)
await expect(setLastStartedBlock(EXECUTION_ID, startedMarker)).resolves.toBe(false)
expect(mockRedis.eval).not.toHaveBeenCalled()
})
})
describe('setLastCompletedBlock', () => {
it('evals with the completed field and endedAt timestamp', async () => {
await setLastCompletedBlock(EXECUTION_ID, completedMarker)
const args = mockRedis.eval.mock.calls[0]
expect(args[3]).toBe('completed')
expect(args[4]).toBe('endedAt')
expect(args[5]).toBe(completedMarker.endedAt)
expect(JSON.parse(args[6] as string)).toEqual(completedMarker)
})
})
describe('getProgressMarkers', () => {
it('parses both markers from the hash', async () => {
mockRedis.hgetall.mockResolvedValueOnce({
started: JSON.stringify(startedMarker),
completed: JSON.stringify(completedMarker),
})
const result = await getProgressMarkers(EXECUTION_ID)
expect(mockRedis.hgetall).toHaveBeenCalledWith(KEY)
expect(result).toEqual({
lastStartedBlock: startedMarker,
lastCompletedBlock: completedMarker,
})
})
it('returns only the present field', async () => {
mockRedis.hgetall.mockResolvedValueOnce({ started: JSON.stringify(startedMarker) })
const result = await getProgressMarkers(EXECUTION_ID)
expect(result).toEqual({ lastStartedBlock: startedMarker })
})
it('returns {} for an empty / missing key', async () => {
mockRedis.hgetall.mockResolvedValueOnce({})
expect(await getProgressMarkers(EXECUTION_ID)).toEqual({})
})
it('returns {} and does not throw on malformed JSON', async () => {
mockRedis.hgetall.mockResolvedValueOnce({ started: '{not json' })
expect(await getProgressMarkers(EXECUTION_ID)).toEqual({})
})
it('drops wrong-shaped JSON so malformed markers never reach clients', async () => {
mockRedis.hgetall.mockResolvedValueOnce({
started: JSON.stringify('just a string'),
completed: JSON.stringify({ blockId: 123, blockName: 'x', blockType: 'api', endedAt: 'z' }),
})
expect(await getProgressMarkers(EXECUTION_ID)).toEqual({})
})
it('strips extra fields, returning only the validated marker shape', async () => {
mockRedis.hgetall.mockResolvedValueOnce({
started: JSON.stringify({ ...startedMarker, secret: 'leak', extra: 1 }),
})
expect(await getProgressMarkers(EXECUTION_ID)).toEqual({ lastStartedBlock: startedMarker })
})
it('returns {} when Redis is unavailable', async () => {
mockGetRedisClient.mockReturnValue(null)
expect(await getProgressMarkers(EXECUTION_ID)).toEqual({})
expect(mockRedis.hgetall).not.toHaveBeenCalled()
})
it('returns null when the Redis read fails so callers do not clear the only copy', async () => {
mockRedis.hgetall.mockRejectedValueOnce(new Error('redis down'))
expect(await getProgressMarkers(EXECUTION_ID)).toBeNull()
})
})
describe('clearProgressMarkers', () => {
it('deletes the key', async () => {
await clearProgressMarkers(EXECUTION_ID)
expect(mockRedis.del).toHaveBeenCalledWith(KEY)
})
it('swallows del errors', async () => {
mockRedis.del.mockRejectedValueOnce(new Error('redis down'))
await expect(clearProgressMarkers(EXECUTION_ID)).resolves.toBeUndefined()
})
it('no-ops when Redis is unavailable', async () => {
mockGetRedisClient.mockReturnValue(null)
await clearProgressMarkers(EXECUTION_ID)
expect(mockRedis.del).not.toHaveBeenCalled()
})
})
describe('latest-wins pickers (stale-store safety)', () => {
const older = { ...startedMarker, blockId: 'old', startedAt: '2026-06-27T10:00:00.000Z' }
const newer = { ...startedMarker, blockId: 'new', startedAt: '2026-06-27T10:00:05.000Z' }
it('returns the defined side when the other is undefined', () => {
expect(pickLatestStartedMarker(older, undefined)).toBe(older)
expect(pickLatestStartedMarker(undefined, newer)).toBe(newer)
expect(pickLatestStartedMarker(undefined, undefined)).toBeUndefined()
})
it('picks the later startedAt regardless of argument order (row newer than Redis still wins)', () => {
expect(pickLatestStartedMarker(older, newer)).toBe(newer)
expect(pickLatestStartedMarker(newer, older)).toBe(newer)
})
it('picks the later endedAt for completed markers', () => {
const c1 = { ...completedMarker, endedAt: '2026-06-27T10:00:01.000Z' }
const c2 = { ...completedMarker, endedAt: '2026-06-27T10:00:09.000Z' }
expect(pickLatestCompletedMarker(c1, c2)).toBe(c2)
expect(pickLatestCompletedMarker(c2, c1)).toBe(c2)
})
})
})
@@ -0,0 +1,247 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike as isRecord } from '@sim/utils/object'
import { getRedisClient } from '@/lib/core/config/redis'
import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits'
import type { ExecutionLastCompletedBlock, ExecutionLastStartedBlock } from '@/lib/logs/types'
const logger = createLogger('ExecutionProgressMarkers')
/**
* Live per-block progress markers (`lastStartedBlock` / `lastCompletedBlock`)
* used to be written to `workflow_execution_logs.execution_data` via a
* `jsonb_set` UPDATE on every block start and complete — ~2N row UPDATEs per
* run and the single heaviest write query in the database. They carry no
* value beyond a breadcrumb folded into the final record (no client polls
* them; live progress comes from the executor over WebSocket), so they live
* in Redis during the run and are folded into the single terminal UPDATE at
* completion. See the logs-contention plan for the full rationale.
*/
/** Redis key namespace — matches the `execution:*` family (stream, cancel, budget). */
const PROGRESS_KEY_PREFIX = 'execution:progress:'
const STARTED_FIELD = 'started'
const COMPLETED_FIELD = 'completed'
/**
* Single source of the client used for progress markers. Indirection kept so a
* future split to a dedicated `LOGS_REDIS_URL` is a one-line change here.
*/
function getMarkerClient() {
return getRedisClient()
}
function markerKey(executionId: string): string {
return `${PROGRESS_KEY_PREFIX}${executionId}`
}
/**
* Atomic monotonic write: set the field only when the incoming marker's embedded
* timestamp is >= the stored one, then refresh the TTL — all in one script so
* concurrent block callbacks can't race a read-modify-write. Preserves the exact
* `<=` ordering the legacy SQL used (`COALESCE(stored, '') <= incoming`). ISO
* UTC timestamps compare correctly lexicographically.
*/
const SET_MARKER_SCRIPT = `
local existing = redis.call('HGET', KEYS[1], ARGV[1])
if existing then
local ok, decoded = pcall(cjson.decode, existing)
if ok and type(decoded) == 'table' and decoded[ARGV[2]] and tostring(decoded[ARGV[2]]) > ARGV[3] then
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return 0
end
end
redis.call('HSET', KEYS[1], ARGV[1], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return 1
`
/**
* Write a marker field under the monotonic guard, refreshing the key TTL. The
* TTL is a backstop for executions that die without a terminal/pause boundary
* (deterministic cleanup is {@link clearProgressMarkers}); it mirrors the
* admission-reservation TTL so a crashed run's marker key and slot expire
* together. Returns `true` only when the marker was durably written to Redis,
* so callers can fall back to the SQL path on a missing client or a failure.
*/
async function setMarker(
executionId: string,
field: string,
timestampField: 'startedAt' | 'endedAt',
timestamp: string,
marker: ExecutionLastStartedBlock | ExecutionLastCompletedBlock
): Promise<boolean> {
const redis = getMarkerClient()
if (!redis) return false
try {
await redis.eval(
SET_MARKER_SCRIPT,
1,
markerKey(executionId),
field,
timestampField,
timestamp,
JSON.stringify(marker),
getExecutionReservationTtlMs().toString()
)
return true
} catch (error) {
logger.error(`Failed to persist progress marker for execution ${executionId}`, {
field,
error: toError(error).message,
})
return false
}
}
/**
* Persist the last-started-block marker. Returns `false` (caller should fall
* back to the durable SQL path) when Redis is unavailable or the write fails.
*/
export async function setLastStartedBlock(
executionId: string,
marker: ExecutionLastStartedBlock
): Promise<boolean> {
return setMarker(executionId, STARTED_FIELD, 'startedAt', marker.startedAt, marker)
}
/**
* Persist the last-completed-block marker. Returns `false` (caller should fall
* back to the durable SQL path) when Redis is unavailable or the write fails.
*/
export async function setLastCompletedBlock(
executionId: string,
marker: ExecutionLastCompletedBlock
): Promise<boolean> {
return setMarker(executionId, COMPLETED_FIELD, 'endedAt', marker.endedAt, marker)
}
export interface ExecutionProgressMarkers {
lastStartedBlock?: ExecutionLastStartedBlock
lastCompletedBlock?: ExecutionLastCompletedBlock
}
/**
* Pick the later of two last-started markers by `startedAt`. Markers can split
* across stores — a failed Redis write falls back to the row, so an earlier
* successful Redis write may coexist with a newer row marker (or vice versa).
* Choosing by timestamp keeps the freshest breadcrumb regardless of which store
* holds it. ISO UTC timestamps compare correctly lexicographically.
*/
export function pickLatestStartedMarker(
a: ExecutionLastStartedBlock | undefined,
b: ExecutionLastStartedBlock | undefined
): ExecutionLastStartedBlock | undefined {
if (!a) return b
if (!b) return a
return a.startedAt >= b.startedAt ? a : b
}
/** Pick the later of two last-completed markers by `endedAt`. See {@link pickLatestStartedMarker}. */
export function pickLatestCompletedMarker(
a: ExecutionLastCompletedBlock | undefined,
b: ExecutionLastCompletedBlock | undefined
): ExecutionLastCompletedBlock | undefined {
if (!a) return b
if (!b) return a
return a.endedAt >= b.endedAt ? a : b
}
function safeJsonParse(raw: string | undefined): unknown {
if (!raw) return undefined
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
/**
* Parse a stored last-started marker, rebuilding it from validated fields so a
* stale or wrong-shaped Redis value can never reach API consumers.
*/
function parseStartedMarker(raw: string | undefined): ExecutionLastStartedBlock | undefined {
const v = safeJsonParse(raw)
if (!isRecord(v)) return undefined
const { blockId, blockName, blockType, startedAt } = v
if (
typeof blockId === 'string' &&
typeof blockName === 'string' &&
typeof blockType === 'string' &&
typeof startedAt === 'string'
) {
return { blockId, blockName, blockType, startedAt }
}
return undefined
}
/**
* Parse a stored last-completed marker, rebuilding it from validated fields so a
* stale or wrong-shaped Redis value can never reach API consumers.
*/
function parseCompletedMarker(raw: string | undefined): ExecutionLastCompletedBlock | undefined {
const v = safeJsonParse(raw)
if (!isRecord(v)) return undefined
const { blockId, blockName, blockType, endedAt, success } = v
if (
typeof blockId === 'string' &&
typeof blockName === 'string' &&
typeof blockType === 'string' &&
typeof endedAt === 'string' &&
typeof success === 'boolean'
) {
return { blockId, blockName, blockType, endedAt, success }
}
return undefined
}
/**
* Read both markers for an execution. Returns an empty object when Redis is
* unavailable (markers were never stored here) or the key holds nothing, and
* `null` when the Redis read itself failed — callers must treat `null` as
* "unknown" and skip {@link clearProgressMarkers}, so a transient read error
* never wipes the only copy of markers that are still in Redis.
*/
export async function getProgressMarkers(
executionId: string
): Promise<ExecutionProgressMarkers | null> {
const redis = getMarkerClient()
if (!redis) return {}
try {
const fields = await redis.hgetall(markerKey(executionId))
if (!fields || Object.keys(fields).length === 0) return {}
const result: ExecutionProgressMarkers = {}
const started = parseStartedMarker(fields[STARTED_FIELD])
if (started) result.lastStartedBlock = started
const completed = parseCompletedMarker(fields[COMPLETED_FIELD])
if (completed) result.lastCompletedBlock = completed
return result
} catch (error) {
logger.error(`Failed to read progress markers for execution ${executionId}`, {
error: toError(error).message,
})
return null
}
}
/**
* Delete the markers for an execution. Called at every terminal/pause boundary
* after the durable record has been written, so paused executions (which can
* live indefinitely) hold no Redis keys. Fire-and-forget; no-op without Redis.
*/
export async function clearProgressMarkers(executionId: string): Promise<void> {
const redis = getMarkerClient()
if (!redis) return
try {
await redis.del(markerKey(executionId))
} catch (error) {
logger.error(`Failed to clear progress markers for execution ${executionId}`, {
error: toError(error).message,
})
}
}
@@ -0,0 +1,570 @@
/**
* @vitest-environment node
*/
import { databaseMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/utils/id', () => ({
generateId: vi.fn(() => 'generated-uuid-1'),
generateShortId: vi.fn(() => 'generated-short-1'),
isValidUuid: vi.fn((v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
),
}))
import { SnapshotService } from '@/lib/logs/execution/snapshot/service'
import type { WorkflowState } from '@/lib/logs/types'
const mockState: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Test Agent',
type: 'agent',
position: { x: 100, y: 200 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
height: 0,
},
},
edges: [{ id: 'edge1', source: 'block1', target: 'block2' }],
loops: {},
parallels: {},
}
describe('SnapshotService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('computeStateHash', () => {
it.concurrent('should generate consistent hashes for identical states', () => {
const service = new SnapshotService()
const state: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Test Agent',
type: 'agent',
position: { x: 100, y: 200 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
height: 0,
},
},
edges: [{ id: 'edge1', source: 'block1', target: 'block2' }],
loops: {},
parallels: {},
}
const hash1 = service.computeStateHash(state)
const hash2 = service.computeStateHash(state)
expect(hash1).toBe(hash2)
expect(hash1).toHaveLength(64) // SHA-256 hex string
})
it.concurrent('should ignore position changes', () => {
const service = new SnapshotService()
const baseState: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Test Agent',
type: 'agent',
position: { x: 100, y: 200 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
height: 0,
},
},
edges: [],
loops: {},
parallels: {},
}
const stateWithDifferentPosition: WorkflowState = {
...baseState,
blocks: {
block1: {
...baseState.blocks.block1,
position: { x: 500, y: 600 },
},
},
}
const hash1 = service.computeStateHash(baseState)
const hash2 = service.computeStateHash(stateWithDifferentPosition)
expect(hash1).toBe(hash2)
})
it.concurrent('should detect meaningful changes', () => {
const service = new SnapshotService()
const baseState: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Test Agent',
type: 'agent',
position: { x: 100, y: 200 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'short-input',
value: 'Hello world',
},
},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
height: 0,
},
},
edges: [],
loops: {},
parallels: {},
}
const stateWithDifferentPrompt: WorkflowState = {
...baseState,
blocks: {
block1: {
...baseState.blocks.block1,
// Different subBlock value - this is a meaningful change
subBlocks: {
prompt: {
id: 'prompt',
type: 'short-input',
value: 'Different prompt',
},
},
},
},
}
const hash1 = service.computeStateHash(baseState)
const hash2 = service.computeStateHash(stateWithDifferentPrompt)
expect(hash1).not.toBe(hash2)
})
it.concurrent('should handle edge order consistently', () => {
const service = new SnapshotService()
const state1: WorkflowState = {
blocks: {},
edges: [
{ id: 'edge1', source: 'a', target: 'b' },
{ id: 'edge2', source: 'b', target: 'c' },
],
loops: {},
parallels: {},
}
const state2: WorkflowState = {
blocks: {},
edges: [
{ id: 'edge2', source: 'b', target: 'c' },
{ id: 'edge1', source: 'a', target: 'b' },
],
loops: {},
parallels: {},
}
const hash1 = service.computeStateHash(state1)
const hash2 = service.computeStateHash(state2)
expect(hash1).toBe(hash2) // Should be same despite different order
})
it.concurrent('should handle empty states', () => {
const service = new SnapshotService()
const emptyState: WorkflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
}
const hash = service.computeStateHash(emptyState)
expect(hash).toHaveLength(64)
})
it.concurrent('should handle complex nested structures', () => {
const service = new SnapshotService()
const complexState: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Complex Agent',
type: 'agent',
position: { x: 100, y: 200 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'short-input',
value: 'Test prompt',
},
model: {
id: 'model',
type: 'short-input',
value: 'gpt-4',
},
},
outputs: {
response: { type: 'string', description: 'Agent response' },
},
enabled: true,
horizontalHandles: true,
advancedMode: true,
height: 200,
},
},
edges: [{ id: 'edge1', source: 'block1', target: 'block2', sourceHandle: 'output' }],
loops: {
loop1: {
id: 'loop1',
nodes: ['block1'],
iterations: 10,
loopType: 'for',
},
},
parallels: {
parallel1: {
id: 'parallel1',
nodes: ['block1'],
count: 3,
parallelType: 'count',
},
},
}
const hash = service.computeStateHash(complexState)
expect(hash).toHaveLength(64)
const hash2 = service.computeStateHash(complexState)
expect(hash).toBe(hash2)
})
it.concurrent('should include variables in hash computation', () => {
const service = new SnapshotService()
const stateWithVariables: WorkflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
'var-1': {
id: 'var-1',
name: 'apiKey',
type: 'string',
value: 'secret123',
},
},
}
const stateWithoutVariables: WorkflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
}
const hashWith = service.computeStateHash(stateWithVariables)
const hashWithout = service.computeStateHash(stateWithoutVariables)
expect(hashWith).not.toBe(hashWithout)
})
it.concurrent('should detect changes in variable values', () => {
const service = new SnapshotService()
const state1: WorkflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
'var-1': {
id: 'var-1',
name: 'myVar',
type: 'string',
value: 'value1',
},
},
}
const state2: WorkflowState = {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
'var-1': {
id: 'var-1',
name: 'myVar',
type: 'string',
value: 'value2', // Different value
},
},
}
const hash1 = service.computeStateHash(state1)
const hash2 = service.computeStateHash(state2)
expect(hash1).not.toBe(hash2)
})
it.concurrent('should generate consistent hashes for states with variables', () => {
const service = new SnapshotService()
const stateWithVariables: WorkflowState = {
blocks: {
block1: {
id: 'block1',
name: 'Test',
type: 'agent',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
height: 0,
},
},
edges: [],
loops: {},
parallels: {},
variables: {
'var-1': {
id: 'var-1',
name: 'testVar',
type: 'plain',
value: 'testValue',
},
'var-2': {
id: 'var-2',
name: 'anotherVar',
type: 'number',
value: 42,
},
},
}
const hash1 = service.computeStateHash(stateWithVariables)
const hash2 = service.computeStateHash(stateWithVariables)
expect(hash1).toBe(hash2)
expect(hash1).toHaveLength(64)
})
})
describe('createSnapshotWithDeduplication', () => {
type SnapshotRow = {
id: string
workflowId: string
stateHash: string
stateData: WorkflowState
createdAt: Date
}
/** Mock the insert → values → onConflictDoUpdate → returning chain. */
function mockUpsertReturning(rows: SnapshotRow[]) {
let capturedConflictConfig: Record<string, unknown> | undefined
const onConflictDoUpdate = vi.fn().mockImplementation((config: Record<string, unknown>) => {
capturedConflictConfig = config
return { returning: vi.fn().mockResolvedValue(rows) }
})
const values = vi.fn().mockReturnValue({ onConflictDoUpdate })
databaseMock.db.insert = vi.fn().mockReturnValue({ values })
databaseMock.db.select = vi.fn()
return { values, onConflictDoUpdate, getConflictConfig: () => capturedConflictConfig }
}
it('inserts a new snapshot in a single atomic upsert', async () => {
const service = new SnapshotService()
const workflowId = 'wf-123'
const { values } = mockUpsertReturning([
{
id: 'generated-uuid-1',
workflowId,
stateHash: 'abc123',
stateData: mockState,
createdAt: new Date('2026-02-19T00:00:00Z'),
},
])
const result = await service.createSnapshotWithDeduplication(workflowId, mockState)
expect(values).toHaveBeenCalledWith(
expect.objectContaining({ id: 'generated-uuid-1', workflowId, stateData: mockState })
)
expect(result.snapshot.id).toBe('generated-uuid-1')
expect(result.isNew).toBe(true)
// Single atomic statement — never a follow-up select (which would race with cleanup).
expect(databaseMock.db.select).not.toHaveBeenCalled()
})
it('reuses the existing snapshot atomically when the returned id differs', async () => {
const service = new SnapshotService()
const workflowId = 'wf-123'
mockUpsertReturning([
{
id: 'existing-snapshot-id',
workflowId,
stateHash: 'abc123',
stateData: mockState,
createdAt: new Date('2026-02-19T00:00:00Z'),
},
])
const result = await service.createSnapshotWithDeduplication(workflowId, mockState)
expect(result.snapshot.id).toBe('existing-snapshot-id')
expect(result.isNew).toBe(false)
expect(databaseMock.db.select).not.toHaveBeenCalled()
})
it('SET targets only state_hash on conflict, never the large state_data', async () => {
const service = new SnapshotService()
const workflowId = 'wf-123'
const { onConflictDoUpdate, getConflictConfig } = mockUpsertReturning([
{
id: 'generated-uuid-1',
workflowId,
stateHash: 'abc123',
stateData: mockState,
createdAt: new Date('2026-02-19T00:00:00Z'),
},
])
await service.createSnapshotWithDeduplication(workflowId, mockState)
expect(onConflictDoUpdate).toHaveBeenCalledTimes(1)
const config = getConflictConfig()
expect(config?.target).toBeDefined()
// The crux of this change: the SET touches state_hash only, so the unchanged
// TOASTed state_data jsonb is never rewritten.
expect(config?.set).toHaveProperty('stateHash')
expect(config?.set).not.toHaveProperty('stateData')
})
it('does not throw on concurrent inserts with the same hash', async () => {
const service = new SnapshotService()
const workflowId = 'wf-123'
const newRow: SnapshotRow = {
id: 'generated-uuid-1',
workflowId,
stateHash: 'abc123',
stateData: mockState,
createdAt: new Date('2026-02-19T00:00:00Z'),
}
const existingRow: SnapshotRow = { ...newRow, id: 'existing-snapshot-id' }
let upsertCall = 0
databaseMock.db.insert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue(upsertCall++ === 0 ? [newRow] : [existingRow]),
}),
}),
}))
const [result1, result2] = await Promise.all([
service.createSnapshotWithDeduplication(workflowId, mockState),
service.createSnapshotWithDeduplication(workflowId, mockState),
])
expect(result1.snapshot.id).toBe('generated-uuid-1')
expect(result1.isNew).toBe(true)
expect(result2.snapshot.id).toBe('existing-snapshot-id')
expect(result2.isNew).toBe(false)
})
})
describe('cleanupOrphanedSnapshots', () => {
function setupCleanupMocks(selectBatches: Array<Array<{ id: string }>>) {
const limitFn = vi.fn()
for (const batch of selectBatches) limitFn.mockResolvedValueOnce(batch)
limitFn.mockResolvedValue([])
const whereSelect = vi.fn().mockReturnValue({ limit: limitFn })
const fromFn = vi.fn().mockReturnValue({ where: whereSelect })
databaseMock.db.select = vi.fn().mockReturnValue({ from: fromFn })
const returningFn = vi.fn().mockImplementation(() => Promise.resolve([]))
const whereDelete = vi.fn().mockReturnValue({ returning: returningFn })
let batchIdx = 0
const deleteFn = vi.fn().mockImplementation(() => {
const batch = selectBatches[batchIdx] ?? []
batchIdx++
returningFn.mockImplementationOnce(() => Promise.resolve(batch.map((r) => ({ id: r.id }))))
return { where: whereDelete }
})
databaseMock.db.delete = deleteFn
return { deleteFn }
}
it('returns 0 and skips delete when nothing is orphaned', async () => {
const service = new SnapshotService()
const { deleteFn } = setupCleanupMocks([])
const count = await service.cleanupOrphanedSnapshots(7)
expect(count).toBe(0)
expect(deleteFn).not.toHaveBeenCalled()
})
it('stops after the first short batch', async () => {
const service = new SnapshotService()
const partial = Array.from({ length: 3 }, (_, i) => ({ id: `s${i}` }))
const { deleteFn } = setupCleanupMocks([partial])
const count = await service.cleanupOrphanedSnapshots(7)
expect(count).toBe(3)
expect(deleteFn).toHaveBeenCalledTimes(1)
})
it('loops through multiple full batches until exhausted', async () => {
const service = new SnapshotService()
const fullBatch = Array.from({ length: 1000 }, (_, i) => ({ id: `s${i}` }))
const tail = [{ id: 'tail-1' }]
const { deleteFn } = setupCleanupMocks([fullBatch, fullBatch, tail])
const count = await service.cleanupOrphanedSnapshots(7)
expect(count).toBe(2001)
expect(deleteFn).toHaveBeenCalledTimes(3)
})
it('caps at MAX_BATCHES (20 × 1000) even when more rows remain', async () => {
const service = new SnapshotService()
const fullBatch = Array.from({ length: 1000 }, (_, i) => ({ id: `s${i}` }))
const batches = Array.from({ length: 25 }, () => fullBatch)
const { deleteFn } = setupCleanupMocks(batches)
const count = await service.cleanupOrphanedSnapshots(7)
expect(count).toBe(20_000)
expect(deleteFn).toHaveBeenCalledTimes(20)
})
})
})
@@ -0,0 +1,163 @@
import { db } from '@sim/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, lt, notExists, sql } from 'drizzle-orm'
import type {
SnapshotService as ISnapshotService,
SnapshotCreationResult,
WorkflowExecutionSnapshot,
WorkflowExecutionSnapshotInsert,
WorkflowState,
} from '@/lib/logs/types'
import { normalizedStringify, normalizeWorkflowState } from '@/lib/workflows/comparison'
const logger = createLogger('SnapshotService')
export class SnapshotService implements ISnapshotService {
async createSnapshot(
workflowId: string,
state: WorkflowState
): Promise<WorkflowExecutionSnapshot> {
const result = await this.createSnapshotWithDeduplication(workflowId, state)
return result.snapshot
}
async createSnapshotWithDeduplication(
workflowId: string,
state: WorkflowState
): Promise<SnapshotCreationResult> {
const stateHash = this.computeStateHash(state)
const snapshotData: WorkflowExecutionSnapshotInsert = {
id: generateId(),
workflowId,
stateHash,
stateData: state,
}
/**
* Insert the snapshot, or — when an identical (workflowId, stateHash) row
* already exists — return it without rewriting the large stateData jsonb.
*
* The hash is a sha256 of the normalized state, so an existing row's stateData
* is byte-identical; there is nothing to update. The previous implementation
* SET state_data on conflict, which rewrote the full (tens-of-KB) jsonb every
* run. We keep a single atomic upsert — so RETURNING always yields the row and
* there is no race with snapshot cleanup (unlike DO NOTHING + a follow-up
* select) — but SET only the small state_hash column to itself. Under Postgres
* MVCC the unchanged, TOASTed stateData is not rewritten: its existing
* out-of-line storage is reused, so the per-execution write drops from the
* full blob to a tiny heap tuple.
*/
const [upsertedSnapshot] = await db
.insert(workflowExecutionSnapshots)
.values(snapshotData)
.onConflictDoUpdate({
target: [workflowExecutionSnapshots.workflowId, workflowExecutionSnapshots.stateHash],
set: {
stateHash: sql`excluded.state_hash`,
},
})
.returning()
const isNew = upsertedSnapshot.id === snapshotData.id
logger.info(
isNew
? `Created new snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}..., blocks: ${Object.keys(state.blocks || {}).length})`
: `Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)`
)
return {
snapshot: {
...upsertedSnapshot,
stateData: upsertedSnapshot.stateData as WorkflowState,
createdAt: upsertedSnapshot.createdAt.toISOString(),
},
isNew,
}
}
async getSnapshot(id: string): Promise<WorkflowExecutionSnapshot | null> {
const [snapshot] = await db
.select()
.from(workflowExecutionSnapshots)
.where(eq(workflowExecutionSnapshots.id, id))
.limit(1)
if (!snapshot) return null
return {
...snapshot,
stateData: snapshot.stateData as WorkflowState,
createdAt: snapshot.createdAt.toISOString(),
}
}
computeStateHash(state: WorkflowState): string {
const normalizedState = normalizeWorkflowState(state)
const stateString = normalizedStringify(normalizedState)
return sha256Hex(stateString)
}
async cleanupOrphanedSnapshots(olderThanDays: number): Promise<number> {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
const BATCH_SIZE = 1000
const MAX_BATCHES = 20
let totalDeleted = 0
let stoppedEarly = false
for (let batch = 0; batch < MAX_BATCHES; batch++) {
const candidates = await db
.select({ id: workflowExecutionSnapshots.id })
.from(workflowExecutionSnapshots)
.where(
and(
lt(workflowExecutionSnapshots.createdAt, cutoffDate),
notExists(
db
.select({ one: sql`1` })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id))
)
)
)
.limit(BATCH_SIZE)
if (candidates.length === 0) break
const ids = candidates.map((c) => c.id)
const deleted = await db
.delete(workflowExecutionSnapshots)
.where(
and(
inArray(workflowExecutionSnapshots.id, ids),
notExists(
db
.select({ one: sql`1` })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id))
)
)
)
.returning({ id: workflowExecutionSnapshots.id })
totalDeleted += deleted.length
if (candidates.length < BATCH_SIZE) break
if (batch === MAX_BATCHES - 1) stoppedEarly = true
}
logger.info(
`Cleaned up ${totalDeleted} orphaned snapshots older than ${olderThanDays} days${stoppedEarly ? ' (batch cap reached, remainder deferred to next run)' : ''}`
)
return totalDeleted
}
}
export const snapshotService = new SnapshotService()
@@ -0,0 +1,324 @@
import { createLogger } from '@sim/logger'
import type { TraceSpan } from '@/lib/logs/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
const logger = createLogger('IterationGrouping')
/** Counter state for generating sequential container names. */
interface ContainerNameCounters {
loopNumbers: Map<string, number>
parallelNumbers: Map<string, number>
loopCounter: number
parallelCounter: number
}
/**
* Builds a container-level TraceSpan (iteration wrapper or top-level container)
* from its source spans and resolved children.
*/
function buildContainerSpan(opts: {
id: string
name: string
type: string
sourceSpans: TraceSpan[]
children: TraceSpan[]
}): TraceSpan {
const startTimes = opts.sourceSpans.map((s) => new Date(s.startTime).getTime())
const endTimes = opts.sourceSpans.map((s) => new Date(s.endTime).getTime())
// Guard against empty sourceSpans — Math.min/max of empty array returns ±Infinity
// which produces NaN durations and invalid Dates downstream.
const nowMs = Date.now()
const earliestStart = startTimes.length > 0 ? Math.min(...startTimes) : nowMs
const latestEnd = endTimes.length > 0 ? Math.max(...endTimes) : nowMs
const hasErrors = opts.sourceSpans.some((s) => s.status === 'error')
const allErrorsHandled =
hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled)
return {
id: opts.id,
name: opts.name,
type: opts.type,
duration: Math.max(0, latestEnd - earliestStart),
startTime: new Date(earliestStart).toISOString(),
endTime: new Date(latestEnd).toISOString(),
status: hasErrors ? 'error' : 'success',
...(allErrorsHandled && { errorHandled: true }),
children: opts.children,
}
}
/**
* Resolves a container name from normal (non-iteration) spans or assigns a sequential number.
* Strips clone suffixes so all clones of the same container share one name/number.
*/
function resolveContainerName(
containerId: string,
containerType: 'parallel' | 'loop',
normalSpans: TraceSpan[],
counters: ContainerNameCounters
): string {
const originalId = stripCloneSuffixes(containerId)
const matchingBlock = normalSpans.find(
(s) => s.blockId === originalId && s.type === containerType
)
if (matchingBlock?.name) return matchingBlock.name
if (containerType === 'parallel') {
if (!counters.parallelNumbers.has(originalId)) {
counters.parallelNumbers.set(originalId, counters.parallelCounter++)
}
return `Parallel ${counters.parallelNumbers.get(originalId)}`
}
if (!counters.loopNumbers.has(originalId)) {
counters.loopNumbers.set(originalId, counters.loopCounter++)
}
return `Loop ${counters.loopNumbers.get(originalId)}`
}
/**
* Classifies a span's immediate container ID and type from its metadata.
* Returns undefined for non-iteration spans.
*/
function classifySpanContainer(
span: TraceSpan
): { containerId: string; containerType: 'parallel' | 'loop' } | undefined {
if (span.parallelId) {
return { containerId: span.parallelId, containerType: 'parallel' }
}
if (span.loopId) {
return { containerId: span.loopId, containerType: 'loop' }
}
if (span.blockId?.includes('_parallel_')) {
const match = span.blockId.match(/_parallel_([^_]+)_iteration_/)
if (match) {
return { containerId: match[1], containerType: 'parallel' }
}
}
return undefined
}
/**
* Finds the outermost container for a span. For nested spans, this is parentIterations[0].
* For flat spans, this is the span's own immediate container.
*/
function getOutermostContainer(
span: TraceSpan
): { containerId: string; containerType: 'parallel' | 'loop' } | undefined {
if (span.parentIterations && span.parentIterations.length > 0) {
const outermost = span.parentIterations[0]
return {
containerId: outermost.iterationContainerId,
containerType: outermost.iterationType as 'parallel' | 'loop',
}
}
return classifySpanContainer(span)
}
/**
* Builds the iteration-level hierarchy for a container, recursively nesting
* any deeper subflows. Works with both:
* - Direct spans (spans whose immediate container matches)
* - Nested spans (spans with parentIterations pointing through this container)
*/
function buildContainerChildren(
containerType: 'parallel' | 'loop',
containerId: string,
spans: TraceSpan[],
normalSpans: TraceSpan[],
counters: ContainerNameCounters
): TraceSpan[] {
const iterationType = containerType === 'parallel' ? 'parallel-iteration' : 'loop-iteration'
const iterationGroups = new Map<number, TraceSpan[]>()
for (const span of spans) {
let iterIdx: number | undefined
if (
span.parentIterations &&
span.parentIterations.length > 0 &&
span.parentIterations[0].iterationContainerId === containerId
) {
iterIdx = span.parentIterations[0].iterationCurrent
} else {
iterIdx = span.iterationIndex
}
if (iterIdx === undefined) {
logger.warn('Skipping iteration span without iterationIndex', {
spanId: span.id,
blockId: span.blockId,
containerId,
})
continue
}
if (!iterationGroups.has(iterIdx)) iterationGroups.set(iterIdx, [])
iterationGroups.get(iterIdx)!.push(span)
}
const iterationChildren: TraceSpan[] = []
const sortedIterations = Array.from(iterationGroups.entries()).sort(([a], [b]) => a - b)
for (const [iterationIndex, iterSpans] of sortedIterations) {
const directLeaves: TraceSpan[] = []
const deeperSpans: TraceSpan[] = []
for (const span of iterSpans) {
if (
span.parentIterations &&
span.parentIterations.length > 0 &&
span.parentIterations[0].iterationContainerId === containerId
) {
deeperSpans.push({
...span,
parentIterations: span.parentIterations.slice(1),
})
} else {
directLeaves.push({
...span,
name: span.name.replace(/ \(iteration \d+\)$/, ''),
})
}
}
const nestedResult = groupIterationBlocksRecursive(
[...directLeaves, ...deeperSpans],
normalSpans,
counters
)
iterationChildren.push(
buildContainerSpan({
id: `${containerId}-iteration-${iterationIndex}`,
name: `Iteration ${iterationIndex}`,
type: iterationType,
sourceSpans: iterSpans,
children: nestedResult,
})
)
}
return iterationChildren
}
/**
* Core recursive algorithm for grouping iteration blocks.
*
* Handles two cases:
* 1. **Flat** (backward compat): spans have loopId/parallelId + iterationIndex but no
* parentIterations. Grouped by immediate container -> iteration -> leaf.
* 2. **Nested** (new): spans have parentIterations chains. The outermost ancestor in the
* chain determines the top-level container. Iteration spans are peeled one level at a
* time and recursed.
*/
function groupIterationBlocksRecursive(
spans: TraceSpan[],
normalSpans: TraceSpan[],
counters: ContainerNameCounters
): TraceSpan[] {
const result: TraceSpan[] = []
const iterationSpans: TraceSpan[] = []
const nonIterationSpans: TraceSpan[] = []
for (const span of spans) {
if (
(span.name.match(/^(.+) \(iteration (\d+)\)$/) &&
(span.loopId || span.parallelId || span.blockId?.includes('_parallel_'))) ||
(span.parentIterations && span.parentIterations.length > 0)
) {
iterationSpans.push(span)
} else {
nonIterationSpans.push(span)
}
}
const containerIdsWithIterations = new Set<string>()
for (const span of iterationSpans) {
const outermost = getOutermostContainer(span)
if (outermost) containerIdsWithIterations.add(outermost.containerId)
}
const nonContainerSpans = nonIterationSpans.filter(
(span) =>
(span.type !== 'parallel' && span.type !== 'loop') ||
span.status === 'error' ||
(span.blockId && !containerIdsWithIterations.has(span.blockId))
)
if (iterationSpans.length === 0) {
result.push(...nonContainerSpans)
result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
return result
}
const containerGroups = new Map<
string,
{ type: 'parallel' | 'loop'; containerId: string; containerName: string; spans: TraceSpan[] }
>()
for (const span of iterationSpans) {
const outermost = getOutermostContainer(span)
if (!outermost) continue
const { containerId, containerType } = outermost
const groupKey = `${containerType}_${containerId}`
if (!containerGroups.has(groupKey)) {
const containerName = resolveContainerName(containerId, containerType, normalSpans, counters)
containerGroups.set(groupKey, {
type: containerType,
containerId,
containerName,
spans: [],
})
}
containerGroups.get(groupKey)!.spans.push(span)
}
for (const [, group] of containerGroups) {
const { type, containerId, containerName, spans: containerSpans } = group
const iterationChildren = buildContainerChildren(
type,
containerId,
containerSpans,
normalSpans,
counters
)
result.push(
buildContainerSpan({
id: `${type === 'parallel' ? 'parallel' : 'loop'}-execution-${containerId}`,
name: containerName,
type,
sourceSpans: containerSpans,
children: iterationChildren,
})
)
}
result.push(...nonContainerSpans)
result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
return result
}
/**
* Groups iteration-based blocks (parallel and loop) by organizing their iteration spans
* into a hierarchical structure with proper parent-child relationships.
* Supports recursive nesting via parentIterations (e.g., parallel-in-parallel, loop-in-loop).
*/
export function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
const normalSpans = spans.filter((s) => !s.name.match(/^(.+) \(iteration (\d+)\)$/))
const counters: ContainerNameCounters = {
loopNumbers: new Map<string, number>(),
parallelNumbers: new Map<string, number>(),
loopCounter: 1,
parallelCounter: 1,
}
return groupIterationBlocksRecursive(spans, normalSpans, counters)
}
@@ -0,0 +1,390 @@
import { createLogger } from '@sim/logger'
import type { ProviderTiming, TraceSpan } from '@/lib/logs/types'
import {
isConditionBlockType,
isWorkflowBlockType,
stripCustomToolPrefix,
} from '@/executor/constants'
import type {
BlockLog,
BlockToolCall,
NormalizedBlockOutput,
ProviderTimingSegment,
} from '@/executor/types'
const logger = createLogger('SpanFactory')
/** A BlockLog that has already passed the id/type validity check. */
type ValidBlockLog = BlockLog & { blockType: string }
/**
* Creates a TraceSpan from a BlockLog. Returns null for invalid logs.
*
* Children are unified under `span.children` regardless of source:
* - Provider `timeSegments` become model/tool child spans with tool I/O merged in
* - `output.toolCalls` (no segments) become tool child spans
* - Child workflow spans are flattened into children
*/
export function createSpanFromLog(log: BlockLog): TraceSpan | null {
if (!log.blockId || !log.blockType) return null
const validLog = log as ValidBlockLog
const span = createBaseSpan(validLog)
if (!isConditionBlockType(validLog.blockType)) {
enrichWithProviderMetadata(span, validLog)
if (!isWorkflowBlockType(validLog.blockType)) {
const segments = validLog.output?.providerTiming?.timeSegments
span.children = segments
? buildChildrenFromTimeSegments(span, validLog, segments)
: buildChildrenFromToolCalls(span, validLog)
}
}
if (isWorkflowBlockType(validLog.blockType)) {
attachChildWorkflowSpans(span, validLog)
}
return span
}
/** Creates the base span with id, name, type, timing, status, and metadata. */
function createBaseSpan(log: ValidBlockLog): TraceSpan {
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
const output = extractDisplayOutput(log)
const childIds = extractChildWorkflowIds(log.output)
return {
id: spanId,
name: log.blockName ?? log.blockId,
type: log.blockType,
duration: log.durationMs,
startTime: log.startedAt,
endTime: log.endedAt,
status: log.error ? 'error' : 'success',
children: [],
blockId: log.blockId,
executionOrder: log.executionOrder,
input: log.input,
output,
...(childIds ?? {}),
...(log.errorHandled && { errorHandled: true }),
...(log.loopId && { loopId: log.loopId }),
...(log.parallelId && { parallelId: log.parallelId }),
...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }),
...(log.parentIterations?.length && { parentIterations: log.parentIterations }),
}
}
/**
* Strips internal fields from the block output for display and merges
* the block-level error into output so the UI renders it alongside data.
*/
function extractDisplayOutput(log: ValidBlockLog): Record<string, unknown> {
const { childWorkflowSnapshotId, childWorkflowId, ...rest } = log.output ?? {}
return log.error ? { ...rest, error: log.error } : rest
}
/** Pulls child-workflow identifiers off the output so they can live on the span. */
function extractChildWorkflowIds(
output: NormalizedBlockOutput | undefined
): { childWorkflowSnapshotId?: string; childWorkflowId?: string } | undefined {
if (!output) return undefined
const ids: { childWorkflowSnapshotId?: string; childWorkflowId?: string } = {}
if (typeof output.childWorkflowSnapshotId === 'string') {
ids.childWorkflowSnapshotId = output.childWorkflowSnapshotId
}
if (typeof output.childWorkflowId === 'string') {
ids.childWorkflowId = output.childWorkflowId
}
return ids.childWorkflowSnapshotId || ids.childWorkflowId ? ids : undefined
}
/** Enriches a span with provider timing, cost, tokens, and model from block output. */
function enrichWithProviderMetadata(span: TraceSpan, log: ValidBlockLog): void {
const output = log.output
if (!output) return
if (output.providerTiming) {
const pt = output.providerTiming
const timing: ProviderTiming = {
duration: pt.duration,
startTime: pt.startTime,
endTime: pt.endTime,
segments: pt.timeSegments ?? [],
}
span.providerTiming = timing
}
if (output.cost) {
const { input, output: out, total, toolCost } = output.cost
span.cost = {
input,
output: out,
total,
...(typeof toolCost === 'number' && toolCost > 0 ? { toolCost } : {}),
}
}
if (output.tokens) {
const t = output.tokens
const input =
typeof t.input === 'number' ? t.input : typeof t.prompt === 'number' ? t.prompt : undefined
const outputTokens =
typeof t.output === 'number'
? t.output
: typeof t.completion === 'number'
? t.completion
: undefined
const totalExplicit = typeof t.total === 'number' ? t.total : undefined
const total =
totalExplicit ??
(input !== undefined || outputTokens !== undefined
? (input ?? 0) + (outputTokens ?? 0)
: undefined)
span.tokens = {
...(input !== undefined && { input }),
...(outputTokens !== undefined && { output: outputTokens }),
...(total !== undefined && { total }),
}
}
if (typeof output.model === 'string') {
span.model = output.model
}
}
/**
* Builds child spans from provider `timeSegments`, matching tool segments to
* their corresponding tool call I/O by name in sequential order.
*/
function buildChildrenFromTimeSegments(
span: TraceSpan,
log: ValidBlockLog,
segments: ProviderTimingSegment[]
): TraceSpan[] {
const toolCallsByName = groupToolCallsByName(resolveToolCallsList(log.output))
const toolCallIndices = new Map<string, number>()
return segments.map((segment, index) => {
const segmentStartTime = new Date(segment.startTime).toISOString()
let segmentEndTime = new Date(segment.endTime).toISOString()
let segmentDuration = segment.duration
// The final model segment sometimes closes before the block ends (e.g. when
// post-processing runs after the stream). Extend it to the block endTime so
// the Gantt bar fills to the edge rather than leaving a trailing gap.
if (index === segments.length - 1 && segment.type === 'model' && log.endedAt) {
const blockEndMs = new Date(log.endedAt).getTime()
const segmentEndMs = new Date(segment.endTime).getTime()
if (blockEndMs > segmentEndMs) {
segmentEndTime = log.endedAt
segmentDuration = blockEndMs - new Date(segment.startTime).getTime()
}
}
if (segment.type === 'tool') {
const normalizedName = stripCustomToolPrefix(segment.name ?? '')
const callsForName = toolCallsByName.get(normalizedName) ?? []
const currentIndex = toolCallIndices.get(normalizedName) ?? 0
const match = callsForName[currentIndex]
toolCallIndices.set(normalizedName, currentIndex + 1)
const toolChild: TraceSpan = {
id: `${span.id}-segment-${index}`,
name: normalizedName,
type: 'tool',
duration: segment.duration,
startTime: segmentStartTime,
endTime: segmentEndTime,
status: match?.error || segment.errorMessage ? 'error' : 'success',
input: match?.arguments ?? match?.input,
output: match?.error
? { error: match.error, ...(match.result ?? match.output ?? {}) }
: (match?.result ?? match?.output),
}
if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId
if (segment.errorType) toolChild.errorType = segment.errorType
if (segment.errorMessage) toolChild.errorMessage = segment.errorMessage
return toolChild
}
const modelChild: TraceSpan = {
id: `${span.id}-segment-${index}`,
name: segment.name ?? 'Model',
type: 'model',
duration: segmentDuration,
startTime: segmentStartTime,
endTime: segmentEndTime,
status: segment.errorMessage ? 'error' : 'success',
}
if (segment.assistantContent) {
modelChild.output = { content: segment.assistantContent }
}
if (segment.thinkingContent) {
modelChild.thinking = segment.thinkingContent
}
if (segment.toolCalls && segment.toolCalls.length > 0) {
modelChild.modelToolCalls = segment.toolCalls
}
if (segment.finishReason) {
modelChild.finishReason = segment.finishReason
}
if (segment.tokens) {
modelChild.tokens = segment.tokens
}
if (segment.cost) {
modelChild.cost = segment.cost
}
if (typeof segment.ttft === 'number' && segment.ttft >= 0) {
modelChild.ttft = segment.ttft
}
if (span.model) {
modelChild.model = span.model
}
if (segment.provider) {
modelChild.provider = segment.provider
}
if (segment.errorType) {
modelChild.errorType = segment.errorType
}
if (segment.errorMessage) {
modelChild.errorMessage = segment.errorMessage
}
return modelChild
})
}
/**
* Builds tool-call child spans when the provider did not emit `timeSegments`.
* Each tool call becomes a full TraceSpan of `type: 'tool'`.
*/
function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceSpan[] {
const toolCalls = resolveToolCallsList(log.output)
if (toolCalls.length === 0) return []
return toolCalls.map((tc, index) => {
const startTime = tc.startTime ?? log.startedAt
const endTime = tc.endTime ?? log.endedAt
return {
id: `${span.id}-tool-${index}`,
name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'),
type: 'tool',
duration: tc.duration ?? 0,
startTime,
endTime,
status: tc.error ? 'error' : 'success',
input: tc.arguments ?? tc.input,
output: tc.error
? { error: tc.error, ...(tc.result ?? tc.output ?? {}) }
: (tc.result ?? tc.output),
}
})
}
/** Groups tool calls by their stripped name for sequential matching against segments. */
function groupToolCallsByName(toolCalls: BlockToolCall[]): Map<string, BlockToolCall[]> {
const byName = new Map<string, BlockToolCall[]>()
for (const tc of toolCalls) {
const name = stripCustomToolPrefix(tc.name ?? '')
const list = byName.get(name)
if (list) list.push(tc)
else byName.set(name, [tc])
}
return byName
}
/**
* Resolves the tool calls list from block output. Providers write a normalized
* `{list, count}` container; a legacy streaming path embeds calls under
* `executionData.output.toolCalls`. The `Array.isArray` branches guard against
* persisted logs from before the container shape was normalized, where
* `toolCalls` was stored as a plain array — still observed in older DB rows.
*/
function resolveToolCallsList(output: NormalizedBlockOutput | undefined): BlockToolCall[] {
if (!output) return []
const direct = output.toolCalls
if (direct) {
if (Array.isArray(direct)) return direct
if (direct.list) return direct.list
logger.warn('Unexpected toolCalls shape on block output — no list extracted', {
shape: typeof direct,
})
return []
}
const legacy = (output.executionData as { output?: { toolCalls?: unknown } } | undefined)?.output
?.toolCalls
if (!legacy) return []
if (Array.isArray(legacy)) return legacy as BlockToolCall[]
if (typeof legacy === 'object' && legacy !== null && 'list' in legacy) {
return ((legacy as { list?: BlockToolCall[] }).list ?? []) as BlockToolCall[]
}
logger.warn('Unexpected legacy executionData.output.toolCalls shape — no list extracted', {
shape: typeof legacy,
})
return []
}
/** Extracts and flattens child workflow trace spans into the parent span's children. */
function attachChildWorkflowSpans(span: TraceSpan, log: ValidBlockLog): void {
const childTraceSpans = log.childTraceSpans ?? log.output?.childTraceSpans
if (!childTraceSpans?.length) return
span.children = flattenWorkflowChildren(childTraceSpans)
span.output = stripChildTraceSpansFromOutput(span.output)
}
/** True when a span is a synthetic workflow wrapper (no blockId). */
function isSyntheticWorkflowWrapper(span: TraceSpan): boolean {
return span.type === 'workflow' && !span.blockId
}
/** Reads nested `childTraceSpans` off a span's output, or `[]` if absent. */
function extractOutputChildren(output: TraceSpan['output']): TraceSpan[] {
const nested = (output as { childTraceSpans?: TraceSpan[] } | undefined)?.childTraceSpans
return Array.isArray(nested) ? nested : []
}
/** Returns a copy of `output` with `childTraceSpans` removed, or undefined unchanged. */
function stripChildTraceSpansFromOutput(
output: TraceSpan['output']
): TraceSpan['output'] | undefined {
if (!output || !('childTraceSpans' in output)) return output
const { childTraceSpans: _, ...rest } = output as Record<string, unknown>
return rest
}
/** Recursively flattens synthetic workflow wrappers, preserving real block spans. */
function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] {
const flattened: TraceSpan[] = []
for (const span of spans) {
if (isSyntheticWorkflowWrapper(span)) {
if (span.children?.length) {
flattened.push(...flattenWorkflowChildren(span.children))
}
continue
}
const directChildren = span.children ?? []
const outputChildren = extractOutputChildren(span.output)
const allChildren = [...directChildren, ...outputChildren]
const nextSpan: TraceSpan = { ...span }
if (allChildren.length > 0) {
nextSpan.children = flattenWorkflowChildren(allChildren)
}
if (outputChildren.length > 0) {
nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output)
}
flattened.push(nextSpan)
}
return flattened
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
import { groupIterationBlocks } from '@/lib/logs/execution/trace-spans/iteration-grouping'
import { createSpanFromLog } from '@/lib/logs/execution/trace-spans/span-factory'
import type { TraceSpan } from '@/lib/logs/types'
import type { BlockLog, ExecutionResult } from '@/executor/types'
/**
* Keys that should be recursively filtered from output display.
* These are internal fields used for execution tracking that shouldn't be shown to users.
*/
const HIDDEN_OUTPUT_KEYS = new Set(['childTraceSpans'])
const SUCCESSFUL_CHILD_ERROR_BOUNDARY_BLOCK_TYPES = new Set(['mothership'])
function setFilteredValue(output: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
/**
* Recursively filters hidden keys from nested objects for cleaner display.
* Used by both executor (for log output) and UI (for display).
*/
export function filterHiddenOutputKeys(value: unknown): unknown {
if (value === null || value === undefined) {
return value
}
if (Array.isArray(value)) {
return value.map((item) => filterHiddenOutputKeys(item))
}
if (typeof value === 'object') {
const filtered: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (HIDDEN_OUTPUT_KEYS.has(key)) {
continue
}
setFilteredValue(filtered, key, filterHiddenOutputKeys(val))
}
return filtered
}
return value
}
/**
* Builds a hierarchical trace span tree from execution logs.
*
* Pipeline:
* 1. Each BlockLog becomes a TraceSpan via `createSpanFromLog`.
* 2. Spans are sorted by start time to form a flat list of root spans.
* 3. Loop/parallel iterations are grouped into container spans via `groupIterationBlocks`.
* 4. A synthetic "Workflow Execution" root wraps the grouped spans and provides
* relative timestamps + total duration derived from the earliest start / latest end.
*/
export function buildTraceSpans(result: ExecutionResult): {
traceSpans: TraceSpan[]
totalDuration: number
} {
if (!result.logs?.length) {
return { traceSpans: [], totalDuration: 0 }
}
const spans = buildRootSpansFromLogs(result.logs)
const grouped = groupIterationBlocks(spans)
if (grouped.length === 0 || !result.metadata) {
const totalDuration = grouped.reduce((sum, span) => sum + span.duration, 0)
return { traceSpans: grouped, totalDuration }
}
return wrapInWorkflowRoot(grouped, spans)
}
/** Converts each BlockLog into a TraceSpan, sorted chronologically by start time. */
function buildRootSpansFromLogs(logs: BlockLog[]): TraceSpan[] {
const spans: TraceSpan[] = []
for (const log of logs) {
const span = createSpanFromLog(log)
if (span) spans.push(span)
}
spans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
return spans
}
/**
* Wraps grouped spans in a synthetic workflow-execution root span using the
* true workflow bounds (earliest start / latest end across all leaf spans).
*/
function wrapInWorkflowRoot(
grouped: TraceSpan[],
leafSpans: TraceSpan[]
): { traceSpans: TraceSpan[]; totalDuration: number } {
let earliestStart = Number.POSITIVE_INFINITY
let latestEnd = 0
for (const span of leafSpans) {
const startTime = new Date(span.startTime).getTime()
const endTime = new Date(span.endTime).getTime()
if (startTime < earliestStart) earliestStart = startTime
if (endTime > latestEnd) latestEnd = endTime
}
const actualWorkflowDuration = latestEnd - earliestStart
addRelativeTimestamps(grouped, earliestStart)
const totalCost = leafSpans.reduce((sum, s) => sum + (s.cost?.total ?? 0), 0)
const workflowSpan: TraceSpan = {
id: 'workflow-execution',
name: 'Workflow Execution',
type: 'workflow',
duration: actualWorkflowDuration,
startTime: new Date(earliestStart).toISOString(),
endTime: new Date(latestEnd).toISOString(),
status: grouped.some(hasUnhandledError) ? 'error' : 'success',
children: grouped,
...(totalCost > 0 && { cost: { total: totalCost } }),
}
return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration }
}
/** Recursively annotates spans with `relativeStartMs` (ms since workflow start). */
function addRelativeTimestamps(spans: TraceSpan[], workflowStartMs: number): void {
for (const span of spans) {
span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs
if (span.children?.length) {
addRelativeTimestamps(span.children, workflowStartMs)
}
}
}
/** True if this span (or any descendant) has an unhandled error. */
function hasUnhandledError(span: TraceSpan): boolean {
if (span.status === 'error' && !span.errorHandled) return true
if (span.status === 'success' && SUCCESSFUL_CHILD_ERROR_BOUNDARY_BLOCK_TYPES.has(span.type)) {
return false
}
return span.children?.some(hasUnhandledError) ?? false
}
+179
View File
@@ -0,0 +1,179 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
const logger = createLogger('TraceStore')
/**
* Key under which the externalized-execution-data pointer (a `__simLargeValueRef`)
* is stored on the slim `execution_data` row.
*/
export const TRACE_STORE_REF_KEY = 'traceStoreRef'
/**
* The only metadata kept inline on the slim row (everything else lives in the
* externalized object). These two describe trace presence/count and uniquely
* survive object expiry — so a reader can still report "trace data expired (N
* spans)" after retention without an object fetch. All other fields
* (environment, trigger, tokens, models, truncation flags, and of course the
* heavy payloads) are in the stored object and recovered on materialize, so
* keeping them inline too would just be duplication.
*/
const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const
/**
* Read-path context. Resolves an externalized payload by storage key, authorized
* via the (already-authorized) workspace — no owner needed.
*/
interface TraceStoreReadContext {
workspaceId: string | null
workflowId: string | null
executionId: string
}
/**
* Write-path context. Requires the execution owner's `userId`: the externalized
* object is tracked in `workspace_files`, whose `user_id` column is NOT NULL
* (FK -> user.id). Requiring it here makes "a write needs an owner" a
* compile-time invariant, so callers must resolve the owner before persisting.
*/
interface TraceStoreWriteContext extends TraceStoreReadContext {
userId: string
}
/**
* Recovers the workflowId embedded in a large-value storage key
* (`execution/{workspaceId}/{workflowId}/{executionId}/<file>`). Used when the
* log row's workflowId has been nulled by workflow deletion.
*/
function workflowIdFromStorageKey(key: string | undefined): string | undefined {
if (!key) return undefined
const parts = key.split('/')
return parts.length >= 5 && parts[0] === 'execution' ? parts[2] : undefined
}
/**
* Recursively removes `cost` from trace spans before persistence. Cost lives in
* exactly one place — the usage_log ledger — so persisted spans carry only
* structure, timing, and tokens (KTD7). Must run AFTER `calculateCostSummary`
* has consumed span costs in memory.
*/
export function stripSpanCosts(spans: unknown): void {
if (!Array.isArray(spans)) return
for (const span of spans) {
if (!span || typeof span !== 'object') continue
const record = span as { cost?: unknown; children?: unknown }
if ('cost' in record) record.cost = undefined
if (Array.isArray(record.children)) stripSpanCosts(record.children)
}
}
/**
* Externalizes heavy `execution_data` to object storage as a single large value
* (reusing the execution-context large-value store + its reference/dependency/GC
* machinery — KTD4/KTD8), returning a slim row payload that keeps inline markers
* plus the `__simLargeValueRef` pointer.
*
* On any failure (no scope, oversized, storage error) the original (already
* cost-stripped) execution data is returned unchanged so the log is never lost.
*/
export async function externalizeExecutionData(
executionData: Record<string, unknown>,
context: TraceStoreWriteContext
): Promise<Record<string, unknown>> {
const { workspaceId, workflowId, executionId, userId } = context
// workspaceId/workflowId build the storage key and can be null for
// deleted-workflow rows. userId is type-guaranteed by TraceStoreWriteContext;
// the falsy check is a defensive guard against an empty string. If any are
// missing the durable write can't succeed, so keep the data inline.
if (!workspaceId || !workflowId || !userId) return executionData
try {
const json = JSON.stringify(executionData)
const size = Buffer.byteLength(json, 'utf8')
// storeLargeValue persists to the execution bucket with a conforming key and
// registers owner + dependency closure (trace -> nested span large values),
// so GC keeps nested children alive while this run's log row exists.
const ref = await storeLargeValue(executionData, json, size, {
workspaceId,
workflowId,
executionId,
userId,
requireDurable: true,
})
const { preview: _preview, ...slimRef } = ref
const slim: Record<string, unknown> = { [TRACE_STORE_REF_KEY]: slimRef }
for (const key of INLINE_MARKER_KEYS) {
if (key in executionData) slim[key] = executionData[key]
}
return slim
} catch (error) {
logger.warn('Failed to externalize execution data; keeping inline', {
executionId,
error: toError(error).message,
})
return executionData
}
}
/**
* Resolves an `execution_data` row into its full form for reads. When the row
* carries a trace-store pointer, the payload is materialized from storage and
* merged with the inline markers; otherwise the row is returned unchanged
* (inline / pre-externalization runs). One level only — nested span
* `__simLargeValueRef` stubs remain as previews, matching prior behavior.
*
* Returns metadata-only (the slim row minus the pointer) if the object is
* missing/unreadable (e.g. post-retention) so reads degrade rather than crash.
*/
export async function materializeExecutionData(
executionData: Record<string, unknown> | null | undefined,
context: TraceStoreReadContext
): Promise<Record<string, unknown>> {
if (!executionData) return {}
const ref = executionData[TRACE_STORE_REF_KEY]
if (!isLargeValueRef(ref)) return executionData
const { [TRACE_STORE_REF_KEY]: _pointer, ...markers } = executionData
if (!context.workspaceId) return markers
// workflowId is `set null` on workflow delete, but the ref key embeds the
// original workflowId — recover it so deleted-workflow logs stay readable.
// Workspace authorization still comes from the (authorized) caller context.
const workflowId = context.workflowId ?? workflowIdFromStorageKey(ref.key)
if (!workflowId) return markers
try {
const materialized = await materializeLargeValueRef(ref, {
workspaceId: context.workspaceId,
workflowId,
executionId: context.executionId,
maxBytes: ref.size,
// Read-only: the value is already referenced by its own execution; don't
// re-register (or fail) on every view/export.
trackReference: false,
})
if (!materialized || typeof materialized !== 'object') {
logger.warn('Trace store object unavailable; returning metadata only', {
executionId: context.executionId,
key: ref.key,
})
return markers
}
return { ...(materialized as Record<string, unknown>), ...markers }
} catch (error) {
logger.warn('Failed to materialize execution data; returning metadata only', {
executionId: context.executionId,
error: toError(error).message,
})
return markers
}
}