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
}
}
+276
View File
@@ -0,0 +1,276 @@
import { db } from '@sim/db'
import {
jobExecutionLogs,
pausedExecutions,
usageLog,
workflow,
workflowDeploymentVersion,
workflowExecutionLogs,
} from '@sim/db/schema'
import { and, eq, type SQL } from 'drizzle-orm'
import type { CostLedger } from '@/lib/api/contracts/logs'
import {
type ExecutionProgressMarkers,
getProgressMarkers,
pickLatestCompletedMarker,
pickLatestStartedMarker,
} from '@/lib/logs/execution/progress-markers'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
type LookupColumn = 'id' | 'executionId'
async function buildCostLedger(executionId: string): Promise<CostLedger | null> {
const rows = await db
.select({
category: usageLog.category,
description: usageLog.description,
cost: usageLog.cost,
metadata: usageLog.metadata,
})
.from(usageLog)
.where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow')))
if (rows.length === 0) return null
type LedgerItem = CostLedger['items'][number]
const byKey = new Map<string, LedgerItem>()
for (const row of rows) {
const metadata = (row.metadata ?? {}) as { inputTokens?: number; outputTokens?: number }
const category = row.category as LedgerItem['category']
const key = `${category}::${row.description}`
const existing = byKey.get(key)
if (existing) {
existing.cost += Number(row.cost)
if (typeof metadata.inputTokens === 'number') {
existing.inputTokens = Math.max(existing.inputTokens ?? 0, metadata.inputTokens)
}
if (typeof metadata.outputTokens === 'number') {
existing.outputTokens = Math.max(existing.outputTokens ?? 0, metadata.outputTokens)
}
} else {
byKey.set(key, {
category,
description: row.description,
cost: Number(row.cost),
...(typeof metadata.inputTokens === 'number' ? { inputTokens: metadata.inputTokens } : {}),
...(typeof metadata.outputTokens === 'number'
? { outputTokens: metadata.outputTokens }
: {}),
})
}
}
const items = [...byKey.values()]
const total = items.reduce((sum, item) => sum + item.cost, 0)
return { total, items }
}
export function jobCostTotal(raw: unknown): { total: number } | null {
const total = (raw as { total?: unknown } | null | undefined)?.total
const n = total == null ? Number.NaN : Number(total)
return Number.isFinite(n) ? { total: n } : null
}
interface FetchLogDetailArgs {
userId: string
workspaceId: string
lookupColumn: LookupColumn
lookupValue: string
}
/**
* Shared loader for the workflow-log detail shape returned by the by-id and
* by-execution routes. Returns `null` when no matching row exists in either
* the workflow-execution or job-execution tables for this user + workspace.
*
* For in-flight (running/pending) executions, live progress markers are merged
* from Redis, since they are only folded into the row at a terminal/pause
* boundary.
*/
export async function fetchLogDetail({
userId,
workspaceId,
lookupColumn,
lookupValue,
}: FetchLogDetailArgs) {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.hasAccess) return null
const workflowMatch: SQL =
lookupColumn === 'id'
? eq(workflowExecutionLogs.id, lookupValue)
: eq(workflowExecutionLogs.executionId, lookupValue)
const rows = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
level: workflowExecutionLogs.level,
status: workflowExecutionLogs.status,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
costTotal: workflowExecutionLogs.costTotal,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
deploymentVersion: workflowDeploymentVersion.version,
deploymentVersionName: workflowDeploymentVersion.name,
pausedStatus: pausedExecutions.status,
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
pausedResumedCount: pausedExecutions.resumedCount,
})
.from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(
workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
)
.leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId))
.where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId)))
.limit(1)
const log = rows[0]
if (log) {
const workflowSummary = log.workflowId
? {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt?.toISOString() ?? null,
updatedAt: log.workflowUpdatedAt?.toISOString() ?? null,
}
: null
const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0)
const resumedCount = Number(log.pausedResumedCount ?? 0)
const hasPendingPause =
(totalPauseCount > 0 && resumedCount < totalPauseCount) ||
(log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed')
// Cost is sourced exclusively from the usage_log ledger (itemized breakdown)
// and its cost_total projection (run total). The cost jsonb is never read.
const costLedger = await buildCostLedger(log.executionId)
const totalDollars = costLedger?.total ?? (log.costTotal != null ? Number(log.costTotal) : null)
// Trace spans / heavy execution data may live in object storage; resolve the
// pointer here (no-op for inline / pre-externalization rows).
const executionData = await materializeExecutionData(
log.executionData as Record<string, unknown> | null,
{ workspaceId, workflowId: log.workflowId, executionId: log.executionId }
)
const liveMarkers =
log.status === 'running' || log.status === 'pending'
? ((await getProgressMarkers(log.executionId)) ?? {})
: {}
const rowMarkers = (executionData ?? {}) as ExecutionProgressMarkers
const mergedStartedBlock = pickLatestStartedMarker(
liveMarkers.lastStartedBlock,
rowMarkers.lastStartedBlock
)
const mergedCompletedBlock = pickLatestCompletedMarker(
liveMarkers.lastCompletedBlock,
rowMarkers.lastCompletedBlock
)
return {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
deploymentVersionId: log.deploymentVersionId,
deploymentVersion: log.deploymentVersion ?? null,
deploymentVersionName: log.deploymentVersionName ?? null,
level: log.level,
status: log.status,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
workflow: workflowSummary,
jobTitle: null,
cost: totalDollars != null ? { total: totalDollars } : null,
costLedger,
pauseSummary: {
status: log.pausedStatus ?? null,
total: totalPauseCount,
resumed: resumedCount,
},
hasPendingPause,
executionData: {
totalDuration: log.totalDurationMs,
...executionData,
...(mergedStartedBlock ? { lastStartedBlock: mergedStartedBlock } : {}),
...(mergedCompletedBlock ? { lastCompletedBlock: mergedCompletedBlock } : {}),
enhanced: true as const,
},
files: log.files ?? null,
}
}
const jobMatch: SQL =
lookupColumn === 'id'
? eq(jobExecutionLogs.id, lookupValue)
: eq(jobExecutionLogs.executionId, lookupValue)
const jobRows = await db
.select({
id: jobExecutionLogs.id,
executionId: jobExecutionLogs.executionId,
level: jobExecutionLogs.level,
status: jobExecutionLogs.status,
trigger: jobExecutionLogs.trigger,
startedAt: jobExecutionLogs.startedAt,
endedAt: jobExecutionLogs.endedAt,
totalDurationMs: jobExecutionLogs.totalDurationMs,
executionData: jobExecutionLogs.executionData,
cost: jobExecutionLogs.cost,
createdAt: jobExecutionLogs.createdAt,
})
.from(jobExecutionLogs)
.where(and(jobMatch, eq(jobExecutionLogs.workspaceId, workspaceId)))
.limit(1)
const jobLog = jobRows[0]
if (!jobLog) return null
const execData = (jobLog.executionData as Record<string, unknown> | null) ?? {}
return {
id: jobLog.id,
workflowId: null,
executionId: jobLog.executionId,
deploymentVersionId: null,
deploymentVersion: null,
deploymentVersionName: null,
level: jobLog.level,
status: jobLog.status,
duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null,
trigger: jobLog.trigger,
createdAt: jobLog.startedAt.toISOString(),
workflow: null,
jobTitle: ((execData.trigger as Record<string, unknown> | undefined)?.source as string) ?? null,
cost: jobCostTotal(jobLog.cost),
pauseSummary: { status: null, total: 0, resumed: 0 },
hasPendingPause: false,
executionData: {
totalDuration: jobLog.totalDurationMs,
...execData,
enhanced: true as const,
},
files: null,
}
}
+319
View File
@@ -0,0 +1,319 @@
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, eq, gt, gte, inArray, lt, lte, ne, type SQL, sql } from 'drizzle-orm'
import { z } from 'zod'
import type { TimeRange } from '@/stores/logs/filters/types'
interface FilterValues {
timeRange: string
level: string
workflowIds: string[]
folderIds: string[]
triggers: string[]
searchQuery: string
}
/**
* Determines if any filters are currently active.
* @param filters - Current filter values
* @returns True if any filter is active
*/
export function hasActiveFilters(filters: FilterValues): boolean {
return (
filters.timeRange !== 'All time' ||
filters.level !== 'all' ||
filters.workflowIds.length > 0 ||
filters.folderIds.length > 0 ||
filters.triggers.length > 0 ||
filters.searchQuery.trim() !== ''
)
}
/**
* Shared schema for log filter parameters.
* Used by both the logs list API and export API.
*/
export const LogFilterParamsSchema = z.object({
workspaceId: z.string(),
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
executionId: z.string().optional(),
costOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
costValue: z.coerce.number().optional(),
durationOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
durationValue: z.coerce.number().optional(),
})
export type LogFilterParams = z.infer<typeof LogFilterParamsSchema>
/**
* Calculates start date from a time range string.
* Returns null for 'All time' or 'Custom range' to indicate the dates
* should be handled separately.
* @param timeRange - The time range option selected by the user
* @param startDate - Optional start date (YYYY-MM-DD) for custom range
* @returns Date object for the start of the range, or null for 'All time'
*/
export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: string): Date | null {
if (timeRange === 'All time') return null
if (timeRange === 'Custom range') {
if (startDate) {
const date = new Date(startDate)
if (!startDate.includes('T')) date.setHours(0, 0, 0, 0)
return date
}
return null
}
const now = new Date()
switch (timeRange) {
case 'Past 30 minutes':
return new Date(now.getTime() - 30 * 60 * 1000)
case 'Past hour':
return new Date(now.getTime() - 60 * 60 * 1000)
case 'Past 6 hours':
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
case 'Past 12 hours':
return new Date(now.getTime() - 12 * 60 * 60 * 1000)
case 'Past 24 hours':
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
case 'Past 3 days':
return new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
case 'Past 7 days':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
case 'Past 14 days':
return new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
case 'Past 30 days':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
default:
return new Date(0)
}
}
/**
* Gets the end date for a time range.
* Returns null for preset ranges (uses current time as implicit end).
* Returns end of day for custom ranges.
* @param timeRange - The time range option selected by the user
* @param endDate - Optional end date (YYYY-MM-DD) for custom range
* @returns Date object for the end of the range, or null for preset ranges
*/
export function getEndDateFromTimeRange(timeRange: TimeRange, endDate?: string): Date | null {
if (timeRange !== 'Custom range') return null
if (endDate) {
const date = new Date(endDate)
if (!endDate.includes('T')) {
date.setHours(23, 59, 59, 999)
} else {
date.setMilliseconds(999)
}
return date
}
return null
}
type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!='
function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined {
const ids = workflowIds.split(',').filter(Boolean)
if (ids.length > 0) {
return inArray(workflow.id, ids)
}
return undefined
}
function buildFolderIdsCondition(folderIds: string): SQL | undefined {
const ids = folderIds.split(',').filter(Boolean)
if (ids.length > 0) {
return inArray(workflow.folderId, ids)
}
return undefined
}
function buildTriggersCondition(triggers: string): SQL | undefined {
const triggerList = triggers.split(',').filter(Boolean)
if (triggerList.length > 0 && !triggerList.includes('all')) {
return inArray(workflowExecutionLogs.trigger, triggerList)
}
return undefined
}
function buildDateConditions(
startDate?: string,
endDate?: string
): { startCondition?: SQL; endCondition?: SQL } {
const result: { startCondition?: SQL; endCondition?: SQL } = {}
if (startDate) {
result.startCondition = gte(workflowExecutionLogs.startedAt, new Date(startDate))
}
if (endDate) {
result.endCondition = lte(workflowExecutionLogs.startedAt, new Date(endDate))
}
return result
}
function buildSearchConditions(params: {
search?: string
workflowName?: string
folderName?: string
executionId?: string
}): SQL[] {
const conditions: SQL[] = []
if (params.search) {
const searchTerm = `%${params.search}%`
conditions.push(sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions.push(sql`${workflow.name} ILIKE ${nameTerm}`)
}
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions.push(sql`${workflow.name} ILIKE ${folderTerm}`)
}
if (params.executionId) {
conditions.push(eq(workflowExecutionLogs.executionId, params.executionId))
}
return conditions
}
function buildCostCondition(operator: ComparisonOperator, value: number): SQL {
// Indexed projection of the usage_log ledger (dollars); no live aggregation.
const costField = sql`${workflowExecutionLogs.costTotal}`
switch (operator) {
case '=':
return sql`${costField} = ${value}`
case '>':
return sql`${costField} > ${value}`
case '<':
return sql`${costField} < ${value}`
case '>=':
return sql`${costField} >= ${value}`
case '<=':
return sql`${costField} <= ${value}`
case '!=':
return sql`${costField} != ${value}`
}
}
function buildDurationCondition(operator: ComparisonOperator, value: number): SQL | undefined {
const durationField = workflowExecutionLogs.totalDurationMs
switch (operator) {
case '=':
return eq(durationField, value)
case '>':
return gt(durationField, value)
case '<':
return lt(durationField, value)
case '>=':
return gte(durationField, value)
case '<=':
return lte(durationField, value)
case '!=':
return ne(durationField, value)
}
}
/**
* Builds SQL conditions for simple level filtering (used by export API).
* Does not handle complex running/pending states.
*/
export function buildSimpleLevelCondition(level: string): SQL | undefined {
if (!level || level === 'all') return undefined
const levels = level.split(',').filter(Boolean)
if (levels.length === 1) {
return eq(workflowExecutionLogs.level, levels[0])
}
if (levels.length > 1) {
return inArray(workflowExecutionLogs.level, levels)
}
return undefined
}
export interface BuildFilterConditionsOptions {
/**
* Whether to use simple level filtering (just matches level string).
* Set to false to skip level filtering (caller will handle it separately).
*/
useSimpleLevelFilter?: boolean
}
/**
* Builds combined SQL conditions from log filter parameters.
* Returns a single SQL condition that can be used in a WHERE clause.
* @param params - The filter parameters from the request
* @param options - Configuration options for filter building
* @returns Combined SQL condition or undefined if no filters
*/
export function buildFilterConditions(
params: LogFilterParams,
options: BuildFilterConditionsOptions = {}
): SQL | undefined {
const { useSimpleLevelFilter = true } = options
const conditions: SQL[] = []
if (useSimpleLevelFilter && params.level) {
const levelCondition = buildSimpleLevelCondition(params.level)
if (levelCondition) conditions.push(levelCondition)
}
if (params.workflowIds) {
const condition = buildWorkflowIdsCondition(params.workflowIds)
if (condition) conditions.push(condition)
}
if (params.folderIds) {
const condition = buildFolderIdsCondition(params.folderIds)
if (condition) conditions.push(condition)
}
if (params.triggers) {
const condition = buildTriggersCondition(params.triggers)
if (condition) conditions.push(condition)
}
const { startCondition, endCondition } = buildDateConditions(params.startDate, params.endDate)
if (startCondition) conditions.push(startCondition)
if (endCondition) conditions.push(endCondition)
const searchConditions = buildSearchConditions({
search: params.search,
workflowName: params.workflowName,
folderName: params.folderName,
executionId: params.executionId,
})
conditions.push(...searchConditions)
if (params.costOperator && params.costValue !== undefined) {
conditions.push(buildCostCondition(params.costOperator, params.costValue))
}
if (params.durationOperator && params.durationValue !== undefined) {
const condition = buildDurationCondition(params.durationOperator, params.durationValue)
if (condition) conditions.push(condition)
}
if (conditions.length === 0) return undefined
if (conditions.length === 1) return conditions[0]
return and(...conditions)
}
+53
View File
@@ -0,0 +1,53 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
/**
* Expands a CSV of selected folder IDs to include every descendant folder in the
* workspace, so that filtering by a parent folder also matches workflows that
* live in nested subfolders.
*
* Returns the original CSV when there are no descendants (or when the input is
* empty / undefined). Unknown IDs are preserved so the caller's `inArray` check
* behaves the same as today (matches nothing).
*
* Server-only: pulls in the database client. Keep separate from `filters.ts`
* (imported by client hooks) to avoid leaking postgres into the browser bundle.
*/
export async function expandFolderIdsWithDescendants(
workspaceId: string,
folderIdsCsv: string | undefined
): Promise<string | undefined> {
if (!folderIdsCsv) return folderIdsCsv
const seedIds = folderIdsCsv.split(',').filter(Boolean)
if (seedIds.length === 0) return folderIdsCsv
const rows = await db
.select({ id: workflowFolder.id, parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt)))
const childrenByParent = new Map<string, string[]>()
for (const row of rows) {
if (!row.parentId) continue
const list = childrenByParent.get(row.parentId)
if (list) list.push(row.id)
else childrenByParent.set(row.parentId, [row.id])
}
const expanded = new Set<string>(seedIds)
const queue = [...seedIds]
while (queue.length > 0) {
const current = queue.pop() as string
const children = childrenByParent.get(current)
if (!children) continue
for (const childId of children) {
if (!expanded.has(childId)) {
expanded.add(childId)
queue.push(childId)
}
}
}
return Array.from(expanded).join(',')
}
+113
View File
@@ -0,0 +1,113 @@
import { getLatestBlock } from '@/blocks/registry'
import { getAllTriggers } from '@/triggers'
export interface TriggerOption {
value: string
label: string
color: string
}
let cachedTriggerOptions: TriggerOption[] | null = null
let cachedTriggerMetadataMap: Map<string, { label: string; color: string }> | null = null
/**
* Reset cache - useful for HMR in development or testing
*/
export function resetTriggerOptionsCache() {
cachedTriggerOptions = null
cachedTriggerMetadataMap = null
}
/**
* Dynamically generates trigger filter options from the trigger registry and block definitions.
* Results are cached after first call for performance (~98% faster on subsequent calls).
*/
export function getTriggerOptions(): TriggerOption[] {
if (cachedTriggerOptions) {
return cachedTriggerOptions
}
const triggers = getAllTriggers()
const providerMap = new Map<string, TriggerOption>()
const coreTypes: TriggerOption[] = [
{ value: 'manual', label: 'Manual', color: '#6b7280' },
{ value: 'api', label: 'API', color: '#2563eb' },
{ value: 'schedule', label: 'Schedule', color: '#059669' },
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
{ value: 'copilot', label: 'Sim agent', color: '#ec4899' },
{ value: 'mothership', label: 'Sim agent', color: '#ec4899' },
{ value: 'workflow', label: 'Workflow', color: '#0369a1' },
]
for (const trigger of triggers) {
const provider = trigger.provider
// Skip generic webhook and already processed providers
if (!provider || providerMap.has(provider) || provider === 'generic') {
continue
}
const block = getLatestBlock(provider)
providerMap.set(provider, {
value: provider,
label: block?.name || formatProviderName(provider),
color: block?.bgColor || '#6b7280',
})
}
const integrationOptions = Array.from(providerMap.values()).sort((a, b) =>
a.label.localeCompare(b.label)
)
cachedTriggerOptions = [...coreTypes, ...integrationOptions]
return cachedTriggerOptions
}
/**
* Formats a provider name into a display-friendly label
* e.g., "microsoft_teams" -> "Microsoft Teams"
*/
function formatProviderName(provider: string): string {
return provider
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
/**
* Internal: Initialize metadata map for O(1) lookups
* Converts array of options to Map for fast access
*/
function initializeTriggerMetadataMap(): Map<string, { label: string; color: string }> {
if (cachedTriggerMetadataMap) {
return cachedTriggerMetadataMap
}
const options = getTriggerOptions()
cachedTriggerMetadataMap = new Map(
options.map((opt) => [opt.value, { label: opt.label, color: opt.color }])
)
return cachedTriggerMetadataMap
}
/**
* Gets integration metadata (label and color) for a specific trigger type.
*/
export function getIntegrationMetadata(triggerType: string): { label: string; color: string } {
const metadataMap = initializeTriggerMetadataMap()
const found = metadataMap.get(triggerType)
if (found) {
return found
}
return {
label: formatProviderName(triggerType),
color: '#6b7280', // gray
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { selectMock } = vi.hoisted(() => ({ selectMock: vi.fn() }))
vi.mock('@sim/db', () => {
const instance = { select: selectMock }
return { db: instance, dbReplica: instance }
})
// Local drizzle-orm mock: the global mock's `sql` lacks `.as()` and the chain
// mock doesn't support `.orderBy().limit()`. We only need condition/sql builders
// to produce truthy stubs (the mocked db ignores them).
vi.mock('drizzle-orm', () => {
const make = (): Record<string, unknown> => {
const o: Record<string, unknown> = {}
o.as = () => o
o.mapWith = () => o
return o
}
const sql = Object.assign((..._args: unknown[]) => make(), {
raw: (..._args: unknown[]) => make(),
join: (..._args: unknown[]) => make(),
})
const op =
(type: string) =>
(...args: unknown[]) => ({ type, args })
return {
sql,
and: op('and'),
or: op('or'),
eq: op('eq'),
ne: op('ne'),
gt: op('gt'),
gte: op('gte'),
lt: op('lt'),
lte: op('lte'),
inArray: op('inArray'),
isNull: op('isNull'),
isNotNull: op('isNotNull'),
asc: op('asc'),
desc: op('desc'),
}
})
vi.mock('@/lib/logs/folder-expansion', () => ({
expandFolderIdsWithDescendants: vi.fn(async (_ws: string, ids: string | undefined) => ids),
}))
// listLogs gates workspace access at entry; the resolver is tested separately.
vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: vi.fn(async () => ({
exists: true,
hasAccess: true,
canWrite: true,
canAdmin: true,
workspace: { id: 'ws-1', name: 'Test', ownerId: 'user-1', organizationId: null },
})),
}))
import type { ListLogsParams } from './list-logs'
import { decodeCursor, listLogs } from './list-logs'
/** A chainable, thenable query-builder stub that resolves to the given rows. */
function builder(rows: unknown[]) {
const b: Record<string, unknown> = {}
for (const method of ['from', 'leftJoin', 'innerJoin', 'where', 'orderBy', 'limit']) {
b[method] = () => b
}
;(b as { then: unknown }).then = (resolve: (value: unknown) => unknown) => resolve(rows)
return b
}
function workflowRow(overrides: Record<string, unknown> = {}) {
return {
id: 'log-1',
workflowId: 'wf-1',
executionId: 'exec-1',
deploymentVersionId: null,
level: 'info',
status: 'success',
trigger: 'manual',
startedAt: new Date('2026-01-01T00:00:00.000Z'),
endedAt: new Date('2026-01-01T00:00:01.000Z'),
totalDurationMs: 1000,
costTotal: '0.1',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
workflowName: 'My Workflow',
workflowDescription: null,
workflowFolderId: null,
workflowUserId: 'user-1',
workflowWorkspaceId: 'ws-1',
workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'),
workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
pausedStatus: null,
pausedTotalPauseCount: 0,
pausedResumedCount: 0,
deploymentVersion: null,
deploymentVersionName: null,
sortValue: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
}
}
function jobRow(overrides: Record<string, unknown> = {}) {
return {
id: 'job-log-1',
executionId: 'job-exec-1',
level: 'info',
status: 'success',
trigger: 'schedule',
startedAt: new Date('2026-01-01T00:00:05.000Z'),
endedAt: new Date('2026-01-01T00:00:06.000Z'),
totalDurationMs: 1000,
cost: { total: 0.2 },
createdAt: new Date('2026-01-01T00:00:05.000Z'),
jobTitle: 'Nightly report',
sortValue: new Date('2026-01-01T00:00:05.000Z'),
...overrides,
}
}
function baseParams(overrides: Partial<ListLogsParams> = {}): ListLogsParams {
return {
workspaceId: 'ws-1',
limit: 100,
sortBy: 'date',
sortOrder: 'desc',
...overrides,
} as ListLogsParams
}
describe('listLogs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('merges workflow and job rows into summaries', async () => {
selectMock
.mockReturnValueOnce(builder([workflowRow()]))
.mockReturnValueOnce(builder([jobRow()]))
const result = await listLogs(baseParams(), 'user-1')
expect(result.data).toHaveLength(2)
const wf = result.data.find((r) => r.id === 'log-1')!
expect(wf).toMatchObject({
executionId: 'exec-1',
workflowId: 'wf-1',
cost: { total: 0.1 },
duration: '1000ms',
jobTitle: null,
})
const job = result.data.find((r) => r.id === 'job-log-1')!
expect(job).toMatchObject({
executionId: 'job-exec-1',
workflowId: null,
jobTitle: 'Nightly report',
})
expect(result.nextCursor).toBeNull()
})
it('returns a decodable nextCursor when results exceed the limit', async () => {
// limit 1, two workflow rows → page of 1, hasMore true
selectMock
.mockReturnValueOnce(
builder([
workflowRow({ id: 'log-a', sortValue: new Date('2026-01-02T00:00:00.000Z') }),
workflowRow({ id: 'log-b', sortValue: new Date('2026-01-01T00:00:00.000Z') }),
])
)
.mockReturnValueOnce(builder([]))
const result = await listLogs(baseParams({ limit: 1 }), 'user-1')
expect(result.data).toHaveLength(1)
expect(result.nextCursor).not.toBeNull()
const decoded = decodeCursor(result.nextCursor!)
expect(decoded?.id).toBe('log-a')
})
it('excludes job logs when a workflow-specific filter is present', async () => {
selectMock.mockReturnValueOnce(builder([workflowRow()]))
const result = await listLogs(baseParams({ workflowIds: 'wf-1' }), 'user-1')
// Only the workflow query runs; the job query is Promise.resolve([]).
expect(selectMock).toHaveBeenCalledTimes(1)
expect(result.data).toHaveLength(1)
expect(result.data[0].workflowId).toBe('wf-1')
})
})
+454
View File
@@ -0,0 +1,454 @@
import { dbReplica } from '@sim/db'
import {
jobExecutionLogs,
pausedExecutions,
workflow,
workflowDeploymentVersion,
workflowExecutionLogs,
} from '@sim/db/schema'
import {
and,
asc,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
isNull,
lt,
lte,
ne,
or,
type SQL,
sql,
} from 'drizzle-orm'
import type { z } from 'zod'
import type {
ListLogsResponse,
listLogsQuerySchema,
WorkflowLogSummary,
} from '@/lib/api/contracts/logs'
import { jobCostTotal } from '@/lib/logs/fetch-log-detail'
import { buildFilterConditions } from '@/lib/logs/filters'
import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export type ListLogsParams = z.output<typeof listLogsQuerySchema>
type SortBy = 'date' | 'duration' | 'cost' | 'status'
type SortOrder = 'asc' | 'desc'
interface CursorData {
v: string | number | null
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
export function decodeCursor(cursor: string): CursorData | null {
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString())
if (typeof parsed?.id !== 'string') return null
return parsed as CursorData
} catch {
return null
}
}
/**
* Shared logs list query used by the `/api/logs` route and the copilot `query_logs`
* tool. Builds the workflow + job execution-log query (cursor pagination, sort,
* level running/pending logic, job-log merge) from the shared filter params. The
* caller is responsible for authenticating `userId`; this function enforces
* workspace permission via the `permissions` join.
*/
export async function listLogs(params: ListLogsParams, userId: string): Promise<ListLogsResponse> {
const access = await checkWorkspaceAccess(params.workspaceId, userId)
if (!access.hasAccess) {
return { data: [], nextCursor: null }
}
const sortBy = params.sortBy as SortBy
const sortOrder = params.sortOrder as SortOrder
const cursor = params.cursor ? decodeCursor(params.cursor) : null
// Expand selected folders to include descendants (matches the route behavior),
// without mutating the caller's params object.
const folderIds = params.folderIds
? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds)
: params.folderIds
const p: ListLogsParams = { ...params, folderIds }
const workflowSortExpr: SQL<unknown> = (() => {
switch (sortBy) {
case 'duration':
return sql`${workflowExecutionLogs.totalDurationMs}`
case 'cost':
// Indexed projection of the usage_log ledger (dollars); no live aggregation.
return sql`${workflowExecutionLogs.costTotal}`
case 'status':
return sql`${workflowExecutionLogs.status}`
default:
return sql`${workflowExecutionLogs.startedAt}`
}
})()
const jobSortExpr: SQL<unknown> = (() => {
switch (sortBy) {
case 'duration':
return sql`${jobExecutionLogs.totalDurationMs}`
case 'cost':
return sql`(${jobExecutionLogs.cost}->>'total')::numeric`
case 'status':
return sql`${jobExecutionLogs.status}`
default:
return sql`${jobExecutionLogs.startedAt}`
}
})()
const dir = sortOrder === 'asc' ? asc : desc
const nullsLast = sql`NULLS LAST`
const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}`
const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => {
if (!cursor) return undefined
const v = cursor.v
const id = cursor.id
const cmp = sortOrder === 'asc' ? sql`>` : sql`<`
if (v === null) {
return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})`
}
return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)`
}
const fetchSize = p.limit + 1
// Build workflow log conditions
const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, p.workspaceId)]
if (p.level && p.level !== 'all') {
const levels = p.level.split(',').filter(Boolean)
const levelConditions: SQL[] = []
for (const level of levels) {
if (level === 'error') {
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') {
const c = and(
eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt)
)
if (c) levelConditions.push(c)
} else if (level === 'running') {
const c = and(
eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt)
)
if (c) levelConditions.push(c)
} else if (level === 'pending') {
const c = and(
eq(workflowExecutionLogs.level, 'info'),
or(
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
and(
isNotNull(pausedExecutions.status),
sql`${pausedExecutions.status} != 'fully_resumed'`
)
)
)
if (c) levelConditions.push(c)
}
}
if (levelConditions.length > 0) {
workflowConditions.push(
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)!
)
}
}
const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: false })
if (commonFilters) workflowConditions.push(commonFilters)
const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id)
if (workflowCursorCond) workflowConditions.push(workflowCursorCond)
// Decide whether to include job logs
const hasWorkflowSpecificFilters = !!(
p.workflowIds ||
p.folderIds ||
p.workflowName ||
p.folderName
)
const triggersList = p.triggers?.split(',').filter(Boolean) || []
const triggersExcludeJobs =
triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership')
const levelList = p.level && p.level !== 'all' ? p.level.split(',').filter(Boolean) : []
const levelExcludesJobs =
levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info')
const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs
const workflowQuery = dbReplica
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
level: workflowExecutionLogs.level,
status: workflowExecutionLogs.status,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
costTotal: workflowExecutionLogs.costTotal,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
pausedStatus: pausedExecutions.status,
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
pausedResumedCount: pausedExecutions.resumedCount,
deploymentVersion: workflowDeploymentVersion.version,
deploymentVersionName: workflowDeploymentVersion.name,
sortValue: sql<unknown>`${workflowSortExpr}`.as('sort_value'),
})
.from(workflowExecutionLogs)
.leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId))
.leftJoin(
workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.where(and(...workflowConditions))
.orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id))
.limit(fetchSize)
const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)]
if (includeJobLogs) {
if (p.level && p.level !== 'all') {
const levels = p.level.split(',').filter(Boolean)
const jobLevelConditions: SQL[] = []
for (const level of levels) {
if (level === 'error') {
jobLevelConditions.push(eq(jobExecutionLogs.level, 'error'))
} else if (level === 'info') {
const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt))
if (c) jobLevelConditions.push(c)
}
}
if (jobLevelConditions.length > 0) {
jobConditions.push(
jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)!
)
}
}
if (triggersList.length > 0 && !triggersList.includes('all')) {
jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList))
}
if (p.startDate) {
jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(p.startDate)))
}
if (p.endDate) {
jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(p.endDate)))
}
if (p.search) {
jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${p.search}%`}`)
}
if (p.executionId) {
jobConditions.push(eq(jobExecutionLogs.executionId, p.executionId))
}
if (p.costOperator && p.costValue !== undefined) {
const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric`
const ops = {
'=': sql`=`,
'>': sql`>`,
'<': sql`<`,
'>=': sql`>=`,
'<=': sql`<=`,
'!=': sql`!=`,
} as const
jobConditions.push(sql`${costField} ${ops[p.costOperator]} ${p.costValue}`)
}
if (p.durationOperator && p.durationValue !== undefined) {
const durationOps: Record<
string,
(field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined
> = {
'=': (f, v) => eq(f, v),
'>': (f, v) => gt(f, v),
'<': (f, v) => lt(f, v),
'>=': (f, v) => gte(f, v),
'<=': (f, v) => lte(f, v),
'!=': (f, v) => ne(f, v),
}
const durationCond = durationOps[p.durationOperator]?.(
jobExecutionLogs.totalDurationMs,
p.durationValue
)
if (durationCond) jobConditions.push(durationCond)
}
const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id)
if (jobCursorCond) jobConditions.push(jobCursorCond)
}
const jobQuery = includeJobLogs
? dbReplica
.select({
id: jobExecutionLogs.id,
executionId: jobExecutionLogs.executionId,
level: jobExecutionLogs.level,
status: jobExecutionLogs.status,
trigger: jobExecutionLogs.trigger,
startedAt: jobExecutionLogs.startedAt,
endedAt: jobExecutionLogs.endedAt,
totalDurationMs: jobExecutionLogs.totalDurationMs,
cost: jobExecutionLogs.cost,
createdAt: jobExecutionLogs.createdAt,
jobTitle: sql<string | null>`${jobExecutionLogs.executionData}->'trigger'->>'source'`,
sortValue: sql<unknown>`${jobSortExpr}`.as('sort_value'),
})
.from(jobExecutionLogs)
.where(and(...jobConditions))
.orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id))
.limit(fetchSize)
: Promise.resolve([])
const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery])
type RowWithSort = {
id: string
sortValue: unknown
summary: WorkflowLogSummary
}
const workflowMapped: RowWithSort[] = workflowRows.map((log) => {
const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0)
const resumedCount = Number(log.pausedResumedCount ?? 0)
const hasPendingPause =
(totalPauseCount > 0 && resumedCount < totalPauseCount) ||
(log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed')
const summary: WorkflowLogSummary = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
deploymentVersionId: log.deploymentVersionId,
deploymentVersion: log.deploymentVersion ?? null,
deploymentVersionName: log.deploymentVersionName ?? null,
level: log.level,
status: log.status,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
workflow: log.workflowId
? {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt?.toISOString() ?? null,
updatedAt: log.workflowUpdatedAt?.toISOString() ?? null,
}
: null,
jobTitle: null,
// List cost is the cost_total projection (faithful ledger sum). Null until
// completion (running) or until the one-time legacy backfill populates it.
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
pauseSummary: {
status: log.pausedStatus ?? null,
total: totalPauseCount,
resumed: resumedCount,
},
hasPendingPause,
}
return { id: log.id, sortValue: log.sortValue, summary }
})
const jobMapped: RowWithSort[] = (jobRows as Awaited<typeof jobQuery>).map((log) => {
const summary: WorkflowLogSummary = {
id: log.id,
workflowId: null,
executionId: log.executionId,
deploymentVersionId: null,
deploymentVersion: null,
deploymentVersionName: null,
level: log.level,
status: log.status,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
workflow: null,
jobTitle: log.jobTitle ?? null,
cost: jobCostTotal(log.cost),
pauseSummary: { status: null, total: 0, resumed: 0 },
hasPendingPause: false,
}
return { id: log.id, sortValue: log.sortValue, summary }
})
const compareSortValues = (a: unknown, b: unknown): number => {
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime()
if (typeof a === 'number' && typeof b === 'number') return a - b
const aStr = String(a)
const bStr = String(b)
if (sortBy === 'date') {
return new Date(aStr).getTime() - new Date(bStr).getTime()
}
const aNum = Number(aStr)
const bNum = Number(bStr)
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum
return aStr.localeCompare(bStr)
}
const merged = [...workflowMapped, ...jobMapped].sort((a, b) => {
const aNull = a.sortValue === null || a.sortValue === undefined
const bNull = b.sortValue === null || b.sortValue === undefined
// Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent.
if (aNull && !bNull) return 1
if (!aNull && bNull) return -1
if (!aNull && !bNull) {
const cmp = compareSortValues(a.sortValue, b.sortValue)
if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp
}
const idCmp = a.id.localeCompare(b.id)
return sortOrder === 'asc' ? idCmp : -idCmp
})
const page = merged.slice(0, p.limit)
const hasMore = merged.length > p.limit
let nextCursor: string | null = null
if (hasMore && page.length > 0) {
const last = page[page.length - 1]
const v = last.sortValue
const cursorV =
v instanceof Date
? v.toISOString()
: typeof v === 'number' || typeof v === 'string'
? v
: v == null
? null
: String(v)
nextCursor = encodeCursor({ v: cursorV, id: last.id })
}
return {
data: page.map((row) => row.summary),
nextCursor,
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
isLargeArrayManifestMock,
isLargeValueRefMock,
readLargeArrayManifestSliceMock,
materializeLargeArrayManifestMock,
materializeLargeValueRefMock,
} = vi.hoisted(() => ({
isLargeArrayManifestMock: vi.fn(),
isLargeValueRefMock: vi.fn(),
readLargeArrayManifestSliceMock: vi.fn(),
materializeLargeArrayManifestMock: vi.fn(),
materializeLargeValueRefMock: vi.fn(),
}))
vi.mock('@/lib/execution/payloads/large-array-manifest-metadata', () => ({
isLargeArrayManifest: isLargeArrayManifestMock,
}))
vi.mock('@/lib/execution/payloads/large-value-ref', () => ({
isLargeValueRef: isLargeValueRefMock,
}))
vi.mock('@/lib/execution/payloads/large-array-manifest', () => ({
readLargeArrayManifestSlice: readLargeArrayManifestSliceMock,
materializeLargeArrayManifest: materializeLargeArrayManifestMock,
}))
vi.mock('@/lib/execution/payloads/store', () => ({
materializeLargeValueRef: materializeLargeValueRefMock,
}))
import type { TraceSpan } from '@/lib/logs/types'
import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views'
const ctx: LogViewContext = {
workspaceId: 'ws-1',
workflowId: 'wf-1',
executionId: 'exec-1',
}
// Fixture helpers — the mocked type guards key off `__sim`.
const manifest = (totalCount: number, preview: unknown[] = []) => ({
__sim: 'manifest',
totalCount,
preview,
})
const ref = (preview: unknown) => ({ __sim: 'ref', preview, size: 100 })
function span(overrides: Partial<TraceSpan> = {}): TraceSpan {
return {
id: 'span-1',
name: 'Agent 1',
type: 'agent',
duration: 100,
startTime: '2026-01-01T00:00:00.000Z',
endTime: '2026-01-01T00:00:00.100Z',
...overrides,
} as TraceSpan
}
beforeEach(() => {
vi.clearAllMocks()
isLargeArrayManifestMock.mockImplementation((v: any) => v?.__sim === 'manifest')
isLargeValueRefMock.mockImplementation((v: any) => v?.__sim === 'ref')
})
describe('toOverview', () => {
it('keeps timing/cost/hierarchy and omits input/output without materializing refs', () => {
const spans: TraceSpan[] = [
span({
id: 'root',
cost: { total: 0.5 },
input: { secret: 'in' },
output: ref('out-preview') as unknown as Record<string, unknown>,
children: [span({ id: 'child', name: 'Tool', type: 'tool' })],
}),
]
const out = toOverview(spans)
expect(out[0]).toMatchObject({
id: 'root',
name: 'Agent 1',
type: 'agent',
durationMs: 100,
cost: { total: 0.5 },
})
expect(out[0]).not.toHaveProperty('input')
expect(out[0]).not.toHaveProperty('output')
expect(out[0].children?.[0]).toMatchObject({ id: 'child', name: 'Tool' })
expect(materializeLargeArrayManifestMock).not.toHaveBeenCalled()
expect(materializeLargeValueRefMock).not.toHaveBeenCalled()
})
})
describe('toFull', () => {
it('includes inline input/output', async () => {
const out = await toFull([span({ input: { a: 1 }, output: { b: 2 } })], ctx)
expect(out[0]).toMatchObject({ input: { a: 1 }, output: { b: 2 } })
})
it('block scoping returns only the selected subtree', async () => {
const spans: TraceSpan[] = [
span({ id: 's1', blockId: 'blk-a', name: 'A' }),
span({ id: 's2', blockId: 'blk-b', name: 'B', output: { keep: true } }),
]
const out = await toFull(spans, ctx, { blockId: 'blk-b' })
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({ blockId: 'blk-b', output: { keep: true } })
})
it('materializes a large-array manifest field', async () => {
materializeLargeArrayManifestMock.mockResolvedValue([1, 2, 3])
const out = await toFull([span({ output: manifest(3) as any })], ctx)
expect(out[0].output).toEqual([1, 2, 3])
expect(materializeLargeArrayManifestMock).toHaveBeenCalledTimes(1)
})
it('falls back to ref preview when a single ref is unavailable', async () => {
materializeLargeValueRefMock.mockResolvedValue(undefined)
const out = await toFull([span({ output: ref('the-preview') as any })], ctx)
expect(out[0].output).toBe('the-preview')
})
})
describe('grepSpans', () => {
it('matches inline output text and error text', async () => {
const spans = [
span({ output: { msg: 'request timeout occurred' }, errorMessage: 'boom failure' }),
]
const outMatch = await grepSpans(spans, 'timeout', ctx)
expect(outMatch.matches.some((m) => m.field === 'output')).toBe(true)
expect(outMatch.truncated).toBe(false)
const errMatch = await grepSpans(spans, 'boom', ctx)
expect(errMatch.matches.some((m) => m.field === 'error')).toBe(true)
})
it('streams a large-array manifest slice-by-slice with advancing offsets', async () => {
// totalCount 500, batch 200 → starts 0, 200, 400 (3 slices). Needle in slice 3.
readLargeArrayManifestSliceMock.mockImplementation(async (_m: unknown, start: number) => {
if (start === 400) return [{ v: 'found the needle here' }]
return [{ v: 'nothing' }]
})
const spans = [span({ output: manifest(500) as any })]
const result = await grepSpans(spans, 'needle', ctx)
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
const starts = readLargeArrayManifestSliceMock.mock.calls.map((c) => c[1])
expect(starts).toEqual([0, 200, 400])
})
it('caps matches and marks truncated', async () => {
const spans = [span({ id: 'a', name: 'needle one', output: { v: 'needle two' } })]
const result = await grepSpans(spans, 'needle', ctx, { maxMatches: 1 })
expect(result.matches).toHaveLength(1)
expect(result.truncated).toBe(true)
})
it('falls back to ref preview when ref access is rejected (no throw)', async () => {
materializeLargeValueRefMock.mockRejectedValue(new Error('not available in this execution'))
const spans = [span({ output: ref('secret-token') as any })]
const result = await grepSpans(spans, 'secret-token', ctx)
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
})
it('falls back to literal substring on invalid regex', async () => {
const spans = [span({ output: { v: 'value a(b found' } })]
const result = await grepSpans(spans, '(', ctx)
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
})
it('returns empty for empty traceSpans', async () => {
const result = await grepSpans([], 'anything', ctx)
expect(result.matches).toEqual([])
expect(result.truncated).toBe(false)
})
})
+339
View File
@@ -0,0 +1,339 @@
import {
materializeLargeArrayManifest,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import type { LargeValueStoreContext } from '@/lib/execution/payloads/store'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import type { TraceSpan } from '@/lib/logs/types'
/**
* Access/materialization context for resolving large-value refs embedded in a
* trace. Built once per request (by the caller) from the fetched execution log.
*/
export type LogViewContext = LargeValueStoreContext
/** Cap a single (non-array) large-value ref materialization. */
const SINGLE_REF_MAX_BYTES = 4 * 1024 * 1024
/** Items per large-array slice while streaming a grep. */
const ARRAY_SLICE_BATCH = 200
const DEFAULT_MAX_MATCHES = 50
const DEFAULT_MAX_SNIPPET_CHARS = 500
const DEFAULT_MAX_SLICES_SCANNED = 200
// ---------------------------------------------------------------------------
// Overview (Level 2): block tree with timing + cost, NO input/output.
// ---------------------------------------------------------------------------
export interface OverviewSpan {
id: string
blockId?: string
name: string
type: string
status?: string
durationMs: number
cost?: TraceSpan['cost']
children?: OverviewSpan[]
}
/** Project trace spans to a compact overview tree. Never materializes refs. */
export function toOverview(spans: TraceSpan[]): OverviewSpan[] {
return spans.map((s) => {
const node: OverviewSpan = {
id: s.id,
blockId: s.blockId,
name: s.name,
type: s.type,
status: s.status,
durationMs: s.duration ?? 0,
}
if (s.cost) node.cost = s.cost
if (s.children && s.children.length > 0) node.children = toOverview(s.children)
return node
})
}
// ---------------------------------------------------------------------------
// Full (Level 3): block tree WITH materialized input/output.
// ---------------------------------------------------------------------------
export interface FullSpan extends OverviewSpan {
startTime?: string
endTime?: string
input?: unknown
output?: unknown
error?: string
children?: FullSpan[]
}
export interface BlockSelector {
blockId?: string
blockName?: string
}
/**
* Project trace spans to full detail, materializing large-value refs in
* input/output. When a `selector` is given, only the matching span subtree(s)
* are returned (and materialized), so a single block's I/O is loaded instead of
* the whole trace.
*/
export async function toFull(
spans: TraceSpan[],
ctx: LogViewContext,
selector?: BlockSelector
): Promise<FullSpan[]> {
const roots = selectSpans(spans, selector)
return Promise.all(roots.map((s) => fullSpan(s, ctx)))
}
function selectSpans(spans: TraceSpan[], selector?: BlockSelector): TraceSpan[] {
if (!selector || (!selector.blockId && !selector.blockName)) return spans
const out: TraceSpan[] = []
const walk = (list: TraceSpan[]): void => {
for (const s of list) {
const matches =
(selector.blockId !== undefined && s.blockId === selector.blockId) ||
(selector.blockName !== undefined && s.name === selector.blockName)
if (matches) {
out.push(s)
} else if (s.children && s.children.length > 0) {
walk(s.children)
}
}
}
walk(spans)
return out
}
async function fullSpan(s: TraceSpan, ctx: LogViewContext): Promise<FullSpan> {
const node: FullSpan = {
id: s.id,
blockId: s.blockId,
name: s.name,
type: s.type,
status: s.status,
durationMs: s.duration ?? 0,
startTime: s.startTime,
endTime: s.endTime,
}
if (s.cost) node.cost = s.cost
if (s.errorMessage) node.error = s.errorMessage
if (s.input !== undefined) node.input = await materializeField(s.input, ctx)
if (s.output !== undefined) node.output = await materializeField(s.output, ctx)
if (s.children && s.children.length > 0) {
node.children = await Promise.all(s.children.map((c) => fullSpan(c, ctx)))
}
return node
}
/**
* Resolve a span field that may be inline OR a large-value ref/manifest. Falls
* back to the ref `preview` (or a placeholder) when the value is unavailable or
* exceeds caps — never throws.
*/
async function materializeField(value: unknown, ctx: LogViewContext): Promise<unknown> {
if (isLargeArrayManifest(value)) {
try {
return await materializeLargeArrayManifest(value, ctx)
} catch {
return value.preview ?? '[large array unavailable]'
}
}
if (isLargeValueRef(value)) {
try {
const materialized = await materializeLargeValueRef(value, {
...ctx,
maxBytes: ctx.maxBytes ?? SINGLE_REF_MAX_BYTES,
})
return materialized === undefined
? (value.preview ?? '[large value unavailable]')
: materialized
} catch {
return value.preview ?? '[large value unavailable]'
}
}
return value
}
// ---------------------------------------------------------------------------
// Grep (single execution): stream large refs chunk-by-chunk, release each.
// ---------------------------------------------------------------------------
export interface GrepSpanMatch {
spanId: string
blockId?: string
name: string
field: 'name' | 'type' | 'error' | 'input' | 'output'
snippet: string
}
export interface GrepSpansResult {
matches: GrepSpanMatch[]
truncated: boolean
}
export interface GrepSpansOptions {
maxMatches?: number
maxSnippetChars?: number
maxSlicesScanned?: number
}
interface GrepState {
matches: GrepSpanMatch[]
slicesScanned: number
truncated: boolean
maxMatches: number
maxSnippetChars: number
maxSlicesScanned: number
regex: RegExp
}
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildRegex(pattern: string): RegExp {
try {
return new RegExp(pattern, 'i')
} catch {
return new RegExp(escapeRegExp(pattern), 'i')
}
}
function snippetAround(text: string, regex: RegExp, maxChars: number): string {
const m = regex.exec(text)
const index = m ? m.index : 0
const half = Math.floor(maxChars / 2)
const start = Math.max(0, index - half)
const end = Math.min(text.length, start + maxChars)
const prefix = start > 0 ? '…' : ''
const suffix = end < text.length ? '…' : ''
return `${prefix}${text.slice(start, end)}${suffix}`
}
function done(state: GrepState): boolean {
return state.truncated || state.matches.length >= state.maxMatches
}
function recordIfMatch(
text: string,
field: GrepSpanMatch['field'],
span: TraceSpan,
state: GrepState
): void {
if (done(state)) return
state.regex.lastIndex = 0
if (!state.regex.test(text)) return
state.regex.lastIndex = 0
state.matches.push({
spanId: span.id,
blockId: span.blockId,
name: span.name,
field,
snippet: snippetAround(text, state.regex, state.maxSnippetChars),
})
if (state.matches.length >= state.maxMatches) state.truncated = true
}
async function grepField(
value: unknown,
field: 'input' | 'output',
span: TraceSpan,
ctx: LogViewContext,
state: GrepState
): Promise<void> {
if (done(state)) return
if (isLargeArrayManifest(value)) {
let start = 0
while (start < value.totalCount && !done(state)) {
if (state.slicesScanned >= state.maxSlicesScanned) {
state.truncated = true
break
}
let slice: unknown[] | null
try {
slice = await readLargeArrayManifestSlice(value, start, ARRAY_SLICE_BATCH, ctx)
} catch {
// Unavailable chunk: fall back to the manifest preview once and stop.
recordIfMatch(safeStringify(value.preview), field, span, state)
return
}
state.slicesScanned += 1
if (slice.length === 0) break
recordIfMatch(safeStringify(slice), field, span, state)
start += ARRAY_SLICE_BATCH
// Release the batch before fetching the next so peak memory ~= one batch.
slice = null
}
return
}
if (isLargeValueRef(value)) {
let materialized: unknown
try {
materialized = await materializeLargeValueRef(value, {
...ctx,
maxBytes: ctx.maxBytes ?? SINGLE_REF_MAX_BYTES,
})
} catch {
materialized = undefined
}
const text =
materialized === undefined ? safeStringify(value.preview) : safeStringify(materialized)
recordIfMatch(text, field, span, state)
return
}
recordIfMatch(safeStringify(value), field, span, state)
}
function safeStringify(value: unknown): string {
if (value === undefined || value === null) return ''
if (typeof value === 'string') return value
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
/**
* Grep a single execution's trace spans for `pattern`. Inline fields are scanned
* directly; large-array I/O is streamed slice-by-slice (each released before the
* next); single large refs are materialized under a byte cap (falling back to
* the ref preview). Only bounded match snippets are accumulated.
*/
export async function grepSpans(
spans: TraceSpan[],
pattern: string,
ctx: LogViewContext,
opts?: GrepSpansOptions
): Promise<GrepSpansResult> {
const state: GrepState = {
matches: [],
slicesScanned: 0,
truncated: false,
maxMatches: opts?.maxMatches ?? DEFAULT_MAX_MATCHES,
maxSnippetChars: opts?.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS,
maxSlicesScanned: opts?.maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED,
regex: buildRegex(pattern),
}
const walk = async (list: TraceSpan[]): Promise<void> => {
for (const span of list) {
if (done(state)) return
recordIfMatch(span.name, 'name', span, state)
recordIfMatch(span.type, 'type', span, state)
if (span.errorMessage) recordIfMatch(span.errorMessage, 'error', span, state)
if (span.input !== undefined) await grepField(span.input, 'input', span, ctx, state)
if (span.output !== undefined) await grepField(span.output, 'output', span, ctx, state)
if (span.children && span.children.length > 0) await walk(span.children)
}
}
await walk(spans)
return { matches: state.matches, truncated: state.truncated }
}
+605
View File
@@ -0,0 +1,605 @@
/**
* Tests for query language parser for logs search
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
describe('parseQuery', () => {
describe('empty and whitespace input', () => {
it.concurrent('should handle empty string', () => {
const result = parseQuery('')
expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('')
})
it.concurrent('should handle whitespace only', () => {
const result = parseQuery(' ')
expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('')
})
})
describe('simple text search', () => {
it.concurrent('should parse plain text as textSearch', () => {
const result = parseQuery('hello world')
expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('hello world')
})
it.concurrent('should preserve text case', () => {
const result = parseQuery('Hello World')
expect(result.textSearch).toBe('Hello World')
})
})
describe('level filter', () => {
it.concurrent('should parse level:error filter', () => {
const result = parseQuery('level:error')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('level')
expect(result.filters[0].value).toBe('error')
expect(result.filters[0].operator).toBe('=')
})
it.concurrent('should parse level:info filter', () => {
const result = parseQuery('level:info')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('level')
expect(result.filters[0].value).toBe('info')
})
})
describe('status filter (alias for level)', () => {
it.concurrent('should parse status:error filter', () => {
const result = parseQuery('status:error')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('status')
expect(result.filters[0].value).toBe('error')
})
})
describe('workflow filter', () => {
it.concurrent('should parse workflow filter with quoted value', () => {
const result = parseQuery('workflow:"my-workflow"')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('workflow')
expect(result.filters[0].value).toBe('my-workflow')
})
it.concurrent('should parse workflow filter with unquoted value', () => {
const result = parseQuery('workflow:test-workflow')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('workflow')
expect(result.filters[0].value).toBe('test-workflow')
})
})
describe('trigger filter', () => {
it.concurrent('should parse trigger:api filter', () => {
const result = parseQuery('trigger:api')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('trigger')
expect(result.filters[0].value).toBe('api')
})
it.concurrent('should parse trigger:webhook filter', () => {
const result = parseQuery('trigger:webhook')
expect(result.filters[0].value).toBe('webhook')
})
it.concurrent('should parse trigger:schedule filter', () => {
const result = parseQuery('trigger:schedule')
expect(result.filters[0].value).toBe('schedule')
})
it.concurrent('should parse trigger:manual filter', () => {
const result = parseQuery('trigger:manual')
expect(result.filters[0].value).toBe('manual')
})
it.concurrent('should parse trigger:chat filter', () => {
const result = parseQuery('trigger:chat')
expect(result.filters[0].value).toBe('chat')
})
})
describe('cost filter with operators', () => {
it.concurrent('should parse cost:>0.01 filter', () => {
const result = parseQuery('cost:>0.01')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('cost')
expect(result.filters[0].operator).toBe('>')
expect(result.filters[0].value).toBe(0.01)
})
it.concurrent('should parse cost:<0.005 filter', () => {
const result = parseQuery('cost:<0.005')
expect(result.filters[0].operator).toBe('<')
expect(result.filters[0].value).toBe(0.005)
})
it.concurrent('should parse cost:>=0.05 filter', () => {
const result = parseQuery('cost:>=0.05')
expect(result.filters[0].operator).toBe('>=')
expect(result.filters[0].value).toBe(0.05)
})
it.concurrent('should parse cost:<=0.1 filter', () => {
const result = parseQuery('cost:<=0.1')
expect(result.filters[0].operator).toBe('<=')
expect(result.filters[0].value).toBe(0.1)
})
it.concurrent('should parse cost:!=0 filter', () => {
const result = parseQuery('cost:!=0')
expect(result.filters[0].operator).toBe('!=')
expect(result.filters[0].value).toBe(0)
})
it.concurrent('should parse cost:=0 filter', () => {
const result = parseQuery('cost:=0')
expect(result.filters[0].operator).toBe('=')
expect(result.filters[0].value).toBe(0)
})
})
describe('duration filter', () => {
it.concurrent('should parse duration:>5000 (ms) filter', () => {
const result = parseQuery('duration:>5000')
expect(result.filters[0].field).toBe('duration')
expect(result.filters[0].operator).toBe('>')
expect(result.filters[0].value).toBe(5000)
})
it.concurrent('should parse duration with ms suffix', () => {
const result = parseQuery('duration:>500ms')
expect(result.filters[0].value).toBe(500)
})
it.concurrent('should parse duration with s suffix (converts to ms)', () => {
const result = parseQuery('duration:>5s')
expect(result.filters[0].value).toBe(5000)
})
it.concurrent('should parse duration:<1s filter', () => {
const result = parseQuery('duration:<1s')
expect(result.filters[0].operator).toBe('<')
expect(result.filters[0].value).toBe(1000)
})
})
describe('date filter', () => {
it.concurrent('should parse date:today filter', () => {
const result = parseQuery('date:today')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('today')
})
it.concurrent('should parse date:yesterday filter', () => {
const result = parseQuery('date:yesterday')
expect(result.filters[0].value).toBe('yesterday')
})
it.concurrent('should parse date:this-week filter', () => {
const result = parseQuery('date:this-week')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('this-week')
})
it.concurrent('should parse date:last-week filter', () => {
const result = parseQuery('date:last-week')
expect(result.filters[0].value).toBe('last-week')
})
it.concurrent('should parse date:this-month filter', () => {
const result = parseQuery('date:this-month')
expect(result.filters[0].value).toBe('this-month')
})
it.concurrent('should parse year-only format (YYYY)', () => {
const result = parseQuery('date:2024')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024')
})
it.concurrent('should parse month-only format (YYYY-MM)', () => {
const result = parseQuery('date:2024-12')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-12')
})
it.concurrent('should parse full date format (YYYY-MM-DD)', () => {
const result = parseQuery('date:2024-12-25')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-12-25')
})
it.concurrent('should parse date range format (YYYY-MM-DD..YYYY-MM-DD)', () => {
const result = parseQuery('date:2024-01-01..2024-01-15')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-01-01..2024-01-15')
})
})
describe('folder filter', () => {
it.concurrent('should parse folder filter with quoted value', () => {
const result = parseQuery('folder:"My Folder"')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('folder')
expect(result.filters[0].value).toBe('My Folder')
})
})
describe('ID filters', () => {
it.concurrent('should parse executionId filter', () => {
const result = parseQuery('executionId:exec-123-abc')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('executionId')
expect(result.filters[0].value).toBe('exec-123-abc')
})
it.concurrent('should parse workflowId filter', () => {
const result = parseQuery('workflowId:wf-456-def')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('workflowId')
expect(result.filters[0].value).toBe('wf-456-def')
})
it.concurrent('should parse execution filter (alias)', () => {
const result = parseQuery('execution:exec-789')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('execution')
expect(result.filters[0].value).toBe('exec-789')
})
it.concurrent('should parse id filter', () => {
const result = parseQuery('id:some-id-123')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('id')
})
})
describe('combined filters and text', () => {
it.concurrent('should parse multiple filters', () => {
const result = parseQuery('level:error trigger:api')
expect(result.filters).toHaveLength(2)
expect(result.filters[0].field).toBe('level')
expect(result.filters[1].field).toBe('trigger')
expect(result.textSearch).toBe('')
})
it.concurrent('should parse filters with text search', () => {
const result = parseQuery('level:error some search text')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('level')
expect(result.textSearch).toBe('some search text')
})
it.concurrent('should parse text before and after filters', () => {
const result = parseQuery('before level:error after')
expect(result.filters).toHaveLength(1)
expect(result.textSearch).toBe('before after')
})
it.concurrent('should parse complex query with multiple filters and text', () => {
const result = parseQuery(
'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text'
)
expect(result.filters).toHaveLength(4)
expect(result.textSearch).toBe('search text')
})
})
describe('invalid filters', () => {
it.concurrent('should treat unknown field as text', () => {
const result = parseQuery('unknownfield:value')
expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('unknownfield:value')
})
it.concurrent('should handle invalid number for cost', () => {
const result = parseQuery('cost:>abc')
expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('cost:>abc')
})
it.concurrent('should handle invalid number for duration', () => {
const result = parseQuery('duration:>notanumber')
expect(result.filters).toHaveLength(0)
})
})
})
describe('queryToApiParams', () => {
it.concurrent('should return empty object for empty query', () => {
const parsed = parseQuery('')
const params = queryToApiParams(parsed)
expect(Object.keys(params)).toHaveLength(0)
})
it.concurrent('should set search param for text search', () => {
const parsed = parseQuery('hello world')
const params = queryToApiParams(parsed)
expect(params.search).toBe('hello world')
})
it.concurrent('should set level param for level filter', () => {
const parsed = parseQuery('level:error')
const params = queryToApiParams(parsed)
expect(params.level).toBe('error')
})
it.concurrent('should combine multiple level filters with comma', () => {
const parsed = parseQuery('level:error level:info')
const params = queryToApiParams(parsed)
expect(params.level).toBe('error,info')
})
it.concurrent('should set triggers param for trigger filter', () => {
const parsed = parseQuery('trigger:api')
const params = queryToApiParams(parsed)
expect(params.triggers).toBe('api')
})
it.concurrent('should combine multiple trigger filters', () => {
const parsed = parseQuery('trigger:api trigger:webhook')
const params = queryToApiParams(parsed)
expect(params.triggers).toBe('api,webhook')
})
it.concurrent('should set workflowName param for workflow filter', () => {
const parsed = parseQuery('workflow:"my-workflow"')
const params = queryToApiParams(parsed)
expect(params.workflowName).toBe('my-workflow')
})
it.concurrent('should set folderName param for folder filter', () => {
const parsed = parseQuery('folder:"My Folder"')
const params = queryToApiParams(parsed)
expect(params.folderName).toBe('My Folder')
})
it.concurrent('should set workflowIds param for workflowId filter', () => {
const parsed = parseQuery('workflowId:wf-123')
const params = queryToApiParams(parsed)
expect(params.workflowIds).toBe('wf-123')
})
it.concurrent('should set executionId param for executionId filter', () => {
const parsed = parseQuery('executionId:exec-456')
const params = queryToApiParams(parsed)
expect(params.executionId).toBe('exec-456')
})
it.concurrent('should set cost params with operator', () => {
const parsed = parseQuery('cost:>0.01')
const params = queryToApiParams(parsed)
expect(params.costOperator).toBe('>')
expect(params.costValue).toBe('0.01')
})
it.concurrent('should set duration params with operator', () => {
const parsed = parseQuery('duration:>5s')
const params = queryToApiParams(parsed)
expect(params.durationOperator).toBe('>')
expect(params.durationValue).toBe('5000')
})
it('should set startDate for date:today', () => {
const parsed = parseQuery('date:today')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
const startDate = new Date(params.startDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
expect(startDate.getTime()).toBe(today.getTime())
})
it('should set startDate and endDate for date:yesterday', () => {
const parsed = parseQuery('date:yesterday')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
})
it('should set startDate for date:this-week', () => {
const parsed = parseQuery('date:this-week')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
const startDate = new Date(params.startDate)
expect(startDate.getDay()).toBe(0)
})
it('should set startDate and endDate for date:last-week', () => {
const parsed = parseQuery('date:last-week')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
})
it('should set startDate for date:this-month', () => {
const parsed = parseQuery('date:this-month')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
const startDate = new Date(params.startDate)
expect(startDate.getDate()).toBe(1)
})
it.concurrent('should set startDate and endDate for year-only (date:2024)', () => {
const parsed = parseQuery('date:2024')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(0)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(31)
})
it.concurrent('should set startDate and endDate for month-only (date:2024-12)', () => {
const parsed = parseQuery('date:2024-12')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(11)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(31)
})
it.concurrent('should set startDate and endDate for full date (date:2024-12-25)', () => {
const parsed = parseQuery('date:2024-12-25')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(11)
expect(startDate.getDate()).toBe(25)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(25)
})
it.concurrent(
'should set startDate and endDate for date range (date:2024-01-01..2024-01-15)',
() => {
const parsed = parseQuery('date:2024-01-01..2024-01-15')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(0)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(0)
expect(endDate.getDate()).toBe(15)
}
)
it.concurrent('should combine execution filter with text search', () => {
const parsed = {
filters: [
{
field: 'execution',
operator: '=' as const,
value: 'exec-123',
originalValue: 'exec-123',
},
],
textSearch: 'some text',
}
const params = queryToApiParams(parsed)
expect(params.search).toBe('some text exec-123')
})
it.concurrent('should handle complex query with all params', () => {
const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"')
const params = queryToApiParams(parsed)
expect(params.level).toBe('error')
expect(params.triggers).toBe('api')
expect(params.costOperator).toBe('>')
expect(params.costValue).toBe('0.01')
expect(params.workflowName).toBe('test')
})
})
+303
View File
@@ -0,0 +1,303 @@
/**
* Query language parser for logs search
*
* Supports syntax like:
* level:error workflow:"my-workflow" trigger:api cost:>0.005 date:today
*/
export interface ParsedFilter {
field: string
operator: '=' | '>' | '<' | '>=' | '<=' | '!='
value: string | number | boolean
originalValue: string
}
export interface ParsedQuery {
filters: ParsedFilter[]
textSearch: string // Any remaining text not in field:value format
}
const FILTER_FIELDS = {
level: 'string',
status: 'string', // alias for level
workflow: 'string',
trigger: 'string',
execution: 'string',
executionId: 'string',
workflowId: 'string',
id: 'string',
cost: 'number',
duration: 'number',
date: 'date',
folder: 'string',
} as const
type FilterField = keyof typeof FILTER_FIELDS
/**
* Parse a search query string into structured filters and text search
*/
export function parseQuery(query: string): ParsedQuery {
const filters: ParsedFilter[] = []
const tokens: string[] = []
const filterRegex = /(\w+):((?:[><!]=?|=)?(?:"[^"]*"|[^\s]+))/g
let lastIndex = 0
let match
while ((match = filterRegex.exec(query)) !== null) {
const [fullMatch, field, valueWithOperator] = match
const beforeText = query.slice(lastIndex, match.index).trim()
if (beforeText) {
tokens.push(beforeText)
}
const parsedFilter = parseFilter(field, valueWithOperator)
if (parsedFilter) {
filters.push(parsedFilter)
} else {
tokens.push(fullMatch)
}
lastIndex = match.index + fullMatch.length
}
const remainingText = query.slice(lastIndex).trim()
if (remainingText) {
tokens.push(remainingText)
}
return {
filters,
textSearch: tokens.join(' ').trim(),
}
}
/**
* Parse a single field:value filter
*/
function parseFilter(field: string, valueWithOperator: string): ParsedFilter | null {
if (!(field in FILTER_FIELDS)) {
return null
}
const filterField = field as FilterField
const fieldType = FILTER_FIELDS[filterField]
let operator: ParsedFilter['operator'] = '='
let value = valueWithOperator
if (value.startsWith('>=')) {
operator = '>='
value = value.slice(2)
} else if (value.startsWith('<=')) {
operator = '<='
value = value.slice(2)
} else if (value.startsWith('!=')) {
operator = '!='
value = value.slice(2)
} else if (value.startsWith('>')) {
operator = '>'
value = value.slice(1)
} else if (value.startsWith('<')) {
operator = '<'
value = value.slice(1)
} else if (value.startsWith('=')) {
operator = '='
value = value.slice(1)
}
const originalValue = value
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1)
}
let parsedValue: string | number | boolean = value
if (fieldType === 'number') {
if (field === 'duration' && value.endsWith('ms')) {
parsedValue = Number.parseFloat(value.slice(0, -2))
} else if (field === 'duration' && value.endsWith('s')) {
parsedValue = Number.parseFloat(value.slice(0, -1)) * 1000 // Convert to ms
} else {
parsedValue = Number.parseFloat(value)
}
if (Number.isNaN(parsedValue)) {
return null
}
}
return {
field: filterField,
operator,
value: parsedValue,
originalValue,
}
}
/**
* Convert parsed query back to URL parameters for the logs API
*/
export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, string> {
const params: Record<string, string> = {}
if (parsedQuery.textSearch) {
params.search = parsedQuery.textSearch
}
for (const filter of parsedQuery.filters) {
switch (filter.field) {
case 'level':
case 'status':
if (filter.operator === '=') {
const existing = params.level ? params.level.split(',') : []
existing.push(filter.value as string)
params.level = existing.join(',')
}
break
case 'trigger':
if (filter.operator === '=') {
const existing = params.triggers ? params.triggers.split(',') : []
existing.push(filter.value as string)
params.triggers = existing.join(',')
}
break
case 'workflow':
if (filter.operator === '=') {
params.workflowName = filter.value as string
}
break
case 'folder':
if (filter.operator === '=') {
params.folderName = filter.value as string
}
break
case 'execution':
if (filter.operator === '=' && parsedQuery.textSearch) {
params.search = `${parsedQuery.textSearch} ${filter.value}`.trim()
} else if (filter.operator === '=') {
params.search = filter.value as string
}
break
case 'workflowId':
if (filter.operator === '=') {
params.workflowIds = String(filter.value)
}
break
case 'executionId':
if (filter.operator === '=') {
params.executionId = String(filter.value)
}
break
case 'date':
if (filter.operator === '=') {
const dateValue = String(filter.value)
// Handle range syntax: date:2024-01-01..2024-01-15
if (dateValue.includes('..')) {
const [startStr, endStr] = dateValue.split('..')
if (startStr && /^\d{4}-\d{2}-\d{2}$/.test(startStr)) {
const [year, month, day] = startStr.split('-').map(Number)
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
params.startDate = startDate.toISOString()
}
if (endStr && /^\d{4}-\d{2}-\d{2}$/.test(endStr)) {
const [year, month, day] = endStr.split('-').map(Number)
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
params.endDate = endDate.toISOString()
}
} else if (dateValue === 'today') {
const today = new Date()
today.setHours(0, 0, 0, 0)
params.startDate = today.toISOString()
} else if (dateValue === 'yesterday') {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
yesterday.setHours(0, 0, 0, 0)
params.startDate = yesterday.toISOString()
const endOfYesterday = new Date(yesterday)
endOfYesterday.setHours(23, 59, 59, 999)
params.endDate = endOfYesterday.toISOString()
} else if (dateValue === 'this-week') {
const now = new Date()
const dayOfWeek = now.getDay()
const startOfWeek = new Date(now)
startOfWeek.setDate(now.getDate() - dayOfWeek)
startOfWeek.setHours(0, 0, 0, 0)
params.startDate = startOfWeek.toISOString()
} else if (dateValue === 'last-week') {
const now = new Date()
const dayOfWeek = now.getDay()
const startOfThisWeek = new Date(now)
startOfThisWeek.setDate(now.getDate() - dayOfWeek)
startOfThisWeek.setHours(0, 0, 0, 0)
const startOfLastWeek = new Date(startOfThisWeek)
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7)
params.startDate = startOfLastWeek.toISOString()
const endOfLastWeek = new Date(startOfThisWeek)
endOfLastWeek.setMilliseconds(-1)
params.endDate = endOfLastWeek.toISOString()
} else if (dateValue === 'this-month') {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
startOfMonth.setHours(0, 0, 0, 0)
params.startDate = startOfMonth.toISOString()
} else if (/^\d{4}$/.test(dateValue)) {
// Year only: YYYY (e.g., 2024)
const year = Number.parseInt(dateValue, 10)
const startOfYear = new Date(year, 0, 1)
startOfYear.setHours(0, 0, 0, 0)
params.startDate = startOfYear.toISOString()
const endOfYear = new Date(year, 11, 31)
endOfYear.setHours(23, 59, 59, 999)
params.endDate = endOfYear.toISOString()
} else if (/^\d{4}-\d{2}$/.test(dateValue)) {
// Month only: YYYY-MM (e.g., 2024-12)
const [year, month] = dateValue.split('-').map(Number)
const startOfMonth = new Date(year, month - 1, 1)
startOfMonth.setHours(0, 0, 0, 0)
params.startDate = startOfMonth.toISOString()
const endOfMonth = new Date(year, month, 0) // Day 0 of next month = last day of this month
endOfMonth.setHours(23, 59, 59, 999)
params.endDate = endOfMonth.toISOString()
} else if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
// Parse as a single date (YYYY-MM-DD) using local timezone
const [year, month, day] = dateValue.split('-').map(Number)
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
params.startDate = startDate.toISOString()
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
params.endDate = endDate.toISOString()
}
}
break
case 'cost':
params.costOperator = filter.operator
params.costValue = String(filter.value)
break
case 'duration':
params.durationOperator = filter.operator
params.durationValue = String(filter.value)
break
}
}
return params
}
@@ -0,0 +1,495 @@
/**
* Tests for search suggestions functionality in logs search
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
FILTER_DEFINITIONS,
type FolderData,
SearchSuggestions,
type TriggerData,
type WorkflowData,
} from '@/lib/logs/search-suggestions'
describe('FILTER_DEFINITIONS', () => {
it.concurrent('should have level filter definition', () => {
const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level')
expect(levelFilter).toBeDefined()
expect(levelFilter?.label).toBe('Status')
expect(levelFilter?.options).toHaveLength(2)
expect(levelFilter?.options.map((o) => o.value)).toContain('error')
expect(levelFilter?.options.map((o) => o.value)).toContain('info')
})
it.concurrent('should have cost filter definition with multiple options', () => {
const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost')
expect(costFilter).toBeDefined()
expect(costFilter?.label).toBe('Cost')
expect(costFilter?.options.length).toBeGreaterThan(0)
expect(costFilter?.options.map((o) => o.value)).toContain('>0.01')
expect(costFilter?.options.map((o) => o.value)).toContain('<0.005')
})
it.concurrent('should have date filter definition', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
expect(dateFilter).toBeDefined()
expect(dateFilter?.label).toBe('Date')
expect(dateFilter?.options.map((o) => o.value)).toContain('today')
expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday')
})
it.concurrent('should have date filter with all keyword options', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
const values = dateFilter?.options.map((o) => o.value) || []
expect(values).toContain('today')
expect(values).toContain('yesterday')
expect(values).toContain('this-week')
expect(values).toContain('last-week')
expect(values).toContain('this-month')
})
it.concurrent('should have dynamic date examples in date filter', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
const options = dateFilter?.options || []
const specificDate = options.find((o) => o.label === 'Specific date')
expect(specificDate).toBeDefined()
expect(specificDate?.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
const specificMonth = options.find((o) => o.label === 'Specific month')
expect(specificMonth).toBeDefined()
expect(specificMonth?.value).toMatch(/^\d{4}-\d{2}$/)
const specificYear = options.find((o) => o.label === 'Specific year')
expect(specificYear).toBeDefined()
expect(specificYear?.value).toMatch(/^\d{4}$/)
const dateRange = options.find((o) => o.label === 'Date range')
expect(dateRange).toBeDefined()
expect(dateRange?.value).toMatch(/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/)
})
it.concurrent('should have duration filter definition', () => {
const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration')
expect(durationFilter).toBeDefined()
expect(durationFilter?.label).toBe('Duration')
expect(durationFilter?.options.map((o) => o.value)).toContain('>5s')
expect(durationFilter?.options.map((o) => o.value)).toContain('<1s')
})
})
describe('SearchSuggestions', () => {
const mockWorkflows: WorkflowData[] = [
{ id: 'wf-1', name: 'Test Workflow', description: 'A test workflow' },
{ id: 'wf-2', name: 'Production Pipeline', description: 'Main production flow' },
{ id: 'wf-3', name: 'API Handler', description: 'Handles API requests' },
]
const mockFolders: FolderData[] = [
{ id: 'folder-1', name: 'Development' },
{ id: 'folder-2', name: 'Production' },
{ id: 'folder-3', name: 'Testing' },
]
const mockTriggers: TriggerData[] = [
{ value: 'manual', label: 'Manual', color: '#6b7280' },
{ value: 'api', label: 'API', color: '#2563eb' },
{ value: 'schedule', label: 'Schedule', color: '#059669' },
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
{ value: 'slack', label: 'Slack', color: '#4A154B' },
]
describe('constructor', () => {
it.concurrent('should create instance with empty data', () => {
const suggestions = new SearchSuggestions()
expect(suggestions).toBeDefined()
})
it.concurrent('should create instance with provided data', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
expect(suggestions).toBeDefined()
})
})
describe('updateData', () => {
it.concurrent('should update internal data', () => {
const suggestions = new SearchSuggestions()
suggestions.updateData(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:')
expect(result).not.toBeNull()
expect(result?.suggestions.length).toBeGreaterThan(0)
})
})
describe('getSuggestions - empty input', () => {
it.concurrent('should return filter keys list for empty input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-keys')
expect(result?.suggestions.length).toBeGreaterThan(0)
})
it.concurrent('should include core filter keys', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
const filterValues = result?.suggestions.map((s) => s.value)
expect(filterValues).toContain('level:')
expect(filterValues).toContain('cost:')
expect(filterValues).toContain('date:')
expect(filterValues).toContain('duration:')
expect(filterValues).toContain('trigger:')
})
it.concurrent('should include workflow filter when workflows exist', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
const filterValues = result?.suggestions.map((s) => s.value)
expect(filterValues).toContain('workflow:')
})
it.concurrent('should include folder filter when folders exist', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
const filterValues = result?.suggestions.map((s) => s.value)
expect(filterValues).toContain('folder:')
})
it.concurrent('should not include workflow filter when no workflows', () => {
const suggestions = new SearchSuggestions([], mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
const filterValues = result?.suggestions.map((s) => s.value)
expect(filterValues).not.toContain('workflow:')
})
})
describe('getSuggestions - filter values (ending with colon)', () => {
it.concurrent('should return level filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true)
})
it.concurrent('should return cost filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('cost:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true)
})
it.concurrent('should return trigger filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'trigger:api')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true)
})
it.concurrent('should return workflow filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
})
it.concurrent('should return folder filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('folder:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
})
it.concurrent('should return null for unknown filter key', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('unknown:')
expect(result).toBeNull()
})
})
describe('getSuggestions - partial filter values', () => {
it.concurrent('should filter level values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:err')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false)
})
it.concurrent('should filter workflow values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:test')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false)
})
it.concurrent('should filter trigger values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:sch')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true)
})
it.concurrent('should return null when no matches found', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:xyz')
expect(result).toBeNull()
})
})
describe('getSuggestions - plain text search (multi-section)', () => {
it.concurrent('should return multi-section results for plain text', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('test')
expect(result).not.toBeNull()
expect(result?.type).toBe('multi-section')
})
it.concurrent('should include show-all suggestion', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('test')
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
})
it.concurrent('should match workflows by name', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('production')
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true)
})
it.concurrent('should match workflows by description', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('API requests')
expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true)
})
it.concurrent('should match folders by name', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('development')
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
})
it.concurrent('should match triggers by label', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('slack')
expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true)
})
it.concurrent('should match filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('error')
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
})
it.concurrent('should show suggested filters when no matches found', () => {
const suggestions = new SearchSuggestions([], [], [])
const result = suggestions.getSuggestions('xyz123')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
})
})
describe('getSuggestions - case insensitivity', () => {
it.concurrent('should match regardless of case', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const lowerResult = suggestions.getSuggestions('test')
const upperResult = suggestions.getSuggestions('TEST')
const mixedResult = suggestions.getSuggestions('TeSt')
expect(lowerResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
expect(upperResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
expect(mixedResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
})
})
describe('getSuggestions - sorting', () => {
it.concurrent('should sort exact matches first', () => {
const workflows: WorkflowData[] = [
{ id: '1', name: 'API Handler' },
{ id: '2', name: 'API' },
{ id: '3', name: 'Another API Thing' },
]
const suggestions = new SearchSuggestions(workflows, [], [])
const result = suggestions.getSuggestions('api')
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
expect(workflowSuggestions?.[0]?.label).toBe('API')
})
it.concurrent('should sort prefix matches before substring matches', () => {
const workflows: WorkflowData[] = [
{ id: '1', name: 'Contains Test Inside' },
{ id: '2', name: 'Test First' },
]
const suggestions = new SearchSuggestions(workflows, [], [])
const result = suggestions.getSuggestions('test')
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
expect(workflowSuggestions?.[0]?.label).toBe('Test First')
})
})
describe('getSuggestions - result limits', () => {
it.concurrent('should limit workflow results to 8', () => {
const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({
id: `wf-${i}`,
name: `Test Workflow ${i}`,
}))
const suggestions = new SearchSuggestions(manyWorkflows, [], [])
const result = suggestions.getSuggestions('test')
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
expect(workflowSuggestions?.length).toBeLessThanOrEqual(8)
})
it.concurrent('should limit filter value results to 5', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('o')
const filterSuggestions = result?.suggestions.filter(
(s) =>
s.category !== 'show-all' &&
s.category !== 'workflow' &&
s.category !== 'folder' &&
s.category !== 'trigger'
)
expect(filterSuggestions?.length).toBeLessThanOrEqual(5)
})
})
describe('getSuggestions - suggestion structure', () => {
it.concurrent('should include correct properties for filter key suggestions', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('')
const suggestion = result?.suggestions[0]
expect(suggestion).toHaveProperty('id')
expect(suggestion).toHaveProperty('value')
expect(suggestion).toHaveProperty('label')
expect(suggestion).toHaveProperty('category')
})
it.concurrent('should include color for trigger suggestions', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:')
const triggerSuggestion = result?.suggestions.find((s) => s.value === 'trigger:api')
expect(triggerSuggestion?.color).toBeDefined()
})
it.concurrent('should quote workflow names in value', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:')
const workflowSuggestion = result?.suggestions.find((s) => s.label === 'Test Workflow')
expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"')
})
})
describe('getSuggestions - date filter values', () => {
it.concurrent('should return date filter keyword options', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'date:today')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:last-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
})
it.concurrent('should suggest year format when typing a year', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024')).toBe(true)
expect(result?.suggestions.some((s) => s.label === 'Year 2024')).toBe(true)
})
it.concurrent('should suggest month format when typing YYYY-MM', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-12')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-12')).toBe(true)
expect(result?.suggestions.some((s) => s.label === 'Dec 2024')).toBe(true)
})
it.concurrent('should suggest single date and range start when typing full date', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-12-25')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25..')).toBe(true)
})
it.concurrent('should suggest completing range when typing date..', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-01-01..')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.description?.includes('Type end date'))).toBe(true)
})
it.concurrent('should suggest complete range when both dates provided', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-01-01..2024-01-15')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-01-01..2024-01-15')).toBe(true)
expect(result?.suggestions.some((s) => s.description === 'Custom date range')).toBe(true)
})
it.concurrent('should filter date options by partial keyword match', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:this')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(false)
})
})
})
+681
View File
@@ -0,0 +1,681 @@
import type { Suggestion, SuggestionGroup } from '@/app/workspace/[workspaceId]/logs/types'
export interface FilterDefinition {
key: string
label: string
description: string
options: Array<{
value: string
label: string
description?: string
}>
acceptsCustomValue?: boolean
customValueHint?: string
}
export interface WorkflowData {
id: string
name: string
description?: string
}
export interface FolderData {
id: string
name: string
}
export interface TriggerData {
value: string
label: string
color: string
}
/**
* Generates current date examples for the date filter options.
*/
function getDateExamples() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const firstOfMonth = `${year}-${month}-01`
const today = `${year}-${month}-${day}`
const yearMonth = `${year}-${month}`
return { today, firstOfMonth, year: String(year), yearMonth }
}
export const FILTER_DEFINITIONS: FilterDefinition[] = [
{
key: 'level',
label: 'Status',
description: 'Filter by log level',
options: [
{ value: 'error', label: 'Error', description: 'Error logs only' },
{ value: 'info', label: 'Info', description: 'Info logs only' },
],
},
{
key: 'cost',
label: 'Cost',
description: 'Filter by run cost',
options: [
{
value: '>0.01',
label: 'Over 2 credits',
description: 'Runs costing more than 2 credits',
},
{
value: '<0.005',
label: 'Under 1 credit',
description: 'Runs costing less than 1 credit',
},
{
value: '>0.05',
label: 'Over 10 credits',
description: 'Runs costing more than 10 credits',
},
{ value: '=0', label: 'Free', description: 'Free runs' },
{ value: '>0', label: 'Paid', description: 'Runs with cost' },
],
},
{
key: 'date',
label: 'Date',
description: 'Filter by date range',
options: (() => {
const { today, firstOfMonth, year, yearMonth } = getDateExamples()
return [
{ value: 'today', label: 'Today', description: "Today's logs" },
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
{ value: 'this-week', label: 'This week', description: "This week's logs" },
{ value: 'last-week', label: 'Last week', description: "Last week's logs" },
{ value: 'this-month', label: 'This month', description: "This month's logs" },
{ value: today, label: 'Specific date', description: 'YYYY-MM-DD' },
{ value: yearMonth, label: 'Specific month', description: 'YYYY-MM' },
{ value: year, label: 'Specific year', description: 'YYYY' },
{
value: `${firstOfMonth}..${today}`,
label: 'Date range',
description: 'YYYY-MM-DD..YYYY-MM-DD',
},
]
})(),
},
{
key: 'duration',
label: 'Duration',
description: 'Filter by run duration',
options: [
{ value: '>5s', label: 'Over 5s', description: 'Runs longer than 5 seconds' },
{ value: '<1s', label: 'Under 1s', description: 'Runs shorter than 1 second' },
{ value: '>10s', label: 'Over 10s', description: 'Runs longer than 10 seconds' },
{ value: '>30s', label: 'Over 30s', description: 'Runs longer than 30 seconds' },
{ value: '<500ms', label: 'Under 0.5s', description: 'Very fast runs' },
],
},
]
export class SearchSuggestions {
private workflowsData: WorkflowData[]
private foldersData: FolderData[]
private triggersData: TriggerData[]
constructor(
workflowsData: WorkflowData[] = [],
foldersData: FolderData[] = [],
triggersData: TriggerData[] = []
) {
this.workflowsData = workflowsData
this.foldersData = foldersData
this.triggersData = triggersData
}
updateData(
workflowsData: WorkflowData[] = [],
foldersData: FolderData[] = [],
triggersData: TriggerData[] = []
) {
this.workflowsData = workflowsData
this.foldersData = foldersData
this.triggersData = triggersData
}
/**
* Get all triggers from registry data
*/
private getAllTriggers(): TriggerData[] {
return this.triggersData
}
/**
* Get suggestions based ONLY on current input (no cursor position!)
*/
getSuggestions(input: string): SuggestionGroup | null {
const trimmed = input.trim()
if (!trimmed) {
return this.getFilterKeysList()
}
if (trimmed.endsWith(':')) {
const key = trimmed.slice(0, -1)
return this.getFilterValues(key)
}
if (trimmed.includes(':')) {
const [key, partial] = trimmed.split(':')
return this.getFilterValues(key, partial)
}
return this.getMultiSectionResults(trimmed)
}
/**
* Get filter keys list (empty input state)
*/
private getFilterKeysList(): SuggestionGroup {
const suggestions: Suggestion[] = []
for (const filter of FILTER_DEFINITIONS) {
suggestions.push({
id: `filter-key-${filter.key}`,
value: `${filter.key}:`,
label: filter.label,
description: filter.description,
category: 'filters',
})
}
suggestions.push({
id: 'filter-key-trigger',
value: 'trigger:',
label: 'Trigger',
description: 'Filter by trigger type',
category: 'filters',
})
if (this.workflowsData.length > 0) {
suggestions.push({
id: 'filter-key-workflow',
value: 'workflow:',
label: 'Workflow',
description: 'Filter by workflow name',
category: 'filters',
})
}
if (this.foldersData.length > 0) {
suggestions.push({
id: 'filter-key-folder',
value: 'folder:',
label: 'Folder',
description: 'Filter by folder name',
category: 'filters',
})
}
suggestions.push({
id: 'filter-key-workflowId',
value: 'workflowId:',
label: 'Workflow ID',
description: 'Filter by workflow ID',
category: 'filters',
})
suggestions.push({
id: 'filter-key-executionId',
value: 'executionId:',
label: 'Run ID',
description: 'Filter by run ID',
category: 'filters',
})
return {
type: 'filter-keys',
suggestions,
}
}
/**
* Get filter values for a specific key
*/
private getFilterValues(key: string, partial = ''): SuggestionGroup | null {
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key)
if (filterDef) {
const suggestions: Suggestion[] = filterDef.options
.filter(
(opt) =>
!partial ||
opt.value.toLowerCase().includes(partial.toLowerCase()) ||
opt.label.toLowerCase().includes(partial.toLowerCase())
)
.map((opt) => ({
id: `filter-value-${key}-${opt.value}`,
value: `${key}:${opt.value}`,
label: opt.label,
description: opt.description,
category: key as Suggestion['category'],
}))
// Handle custom date input
if (key === 'date' && partial) {
const dateSuggestions = this.getDateSuggestions(partial)
if (dateSuggestions.length > 0) {
suggestions.unshift(...dateSuggestions)
}
}
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: key,
suggestions,
}
: null
}
if (key === 'trigger') {
const allTriggers = this.getAllTriggers()
const suggestions = allTriggers
.filter((t) => !partial || t.label.toLowerCase().includes(partial.toLowerCase()))
.map((t) => ({
id: `filter-value-trigger-${t.value}`,
value: `trigger:${t.value}`,
label: t.label,
description: `${t.label}-triggered runs`,
category: 'trigger' as const,
color: t.color,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'trigger',
suggestions,
}
: null
}
if (key === 'workflow') {
const suggestions = this.workflowsData
.filter((w) => !partial || w.name.toLowerCase().includes(partial.toLowerCase()))
.map((w) => ({
id: `filter-value-workflow-${w.id}`,
value: `workflow:"${w.name}"`,
label: w.name,
description: w.description,
category: 'workflow' as const,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'workflow',
suggestions,
}
: null
}
if (key === 'folder') {
const suggestions = this.foldersData
.filter((f) => !partial || f.name.toLowerCase().includes(partial.toLowerCase()))
.map((f) => ({
id: `filter-value-folder-${f.id}`,
value: `folder:"${f.name}"`,
label: f.name,
category: 'folder' as const,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'folder',
suggestions,
}
: null
}
return null
}
/**
* Get multi-section results for plain text
*/
private getMultiSectionResults(query: string): SuggestionGroup | null {
const sections: Array<{ title: string; suggestions: Suggestion[] }> = []
const allSuggestions: Suggestion[] = []
const showAllSuggestion: Suggestion = {
id: 'show-all',
value: query,
label: `Show all results for "${query}"`,
category: 'show-all',
}
allSuggestions.push(showAllSuggestion)
const matchingFilterValues = this.getMatchingFilterValues(query)
if (matchingFilterValues.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: matchingFilterValues,
})
allSuggestions.push(...matchingFilterValues)
}
const matchingTriggers = this.getMatchingTriggers(query)
if (matchingTriggers.length > 0) {
sections.push({
title: 'TRIGGERS',
suggestions: matchingTriggers,
})
allSuggestions.push(...matchingTriggers)
}
const matchingWorkflows = this.getMatchingWorkflows(query)
if (matchingWorkflows.length > 0) {
sections.push({
title: 'WORKFLOWS',
suggestions: matchingWorkflows,
})
allSuggestions.push(...matchingWorkflows)
}
const matchingFolders = this.getMatchingFolders(query)
if (matchingFolders.length > 0) {
sections.push({
title: 'FOLDERS',
suggestions: matchingFolders,
})
allSuggestions.push(...matchingFolders)
}
if (
matchingFilterValues.length === 0 &&
matchingTriggers.length === 0 &&
matchingWorkflows.length === 0 &&
matchingFolders.length === 0
) {
const filterKeys = this.getFilterKeysList()
if (filterKeys.suggestions.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: filterKeys.suggestions.slice(0, 5),
})
allSuggestions.push(...filterKeys.suggestions.slice(0, 5))
}
}
return allSuggestions.length > 0
? {
type: 'multi-section',
suggestions: allSuggestions,
sections,
}
: null
}
/**
* Get suggestions for custom date input
*/
private getDateSuggestions(partial: string): Suggestion[] {
const suggestions: Suggestion[] = []
// Pattern for year only: YYYY
const yearPattern = /^\d{4}$/
// Pattern for month only: YYYY-MM
const monthPattern = /^\d{4}-\d{2}$/
// Pattern for full date: YYYY-MM-DD
const fullDatePattern = /^\d{4}-\d{2}-\d{2}$/
// Pattern for partial date being typed
const partialDatePattern = /^\d{4}(-\d{0,2})?(-\d{0,2})?$/
// Pattern for date range: YYYY-MM-DD..YYYY-MM-DD (complete or partial)
const rangePattern = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
const partialRangePattern = /^(\d{4}-\d{2}-\d{2})\.\.?$/
// Check if it's a complete date range
if (rangePattern.test(partial)) {
const [startDate, endDate] = partial.split('..')
suggestions.push({
id: `date-range-${partial}`,
value: `date:${partial}`,
label: `${this.formatDateLabel(startDate)} to ${this.formatDateLabel(endDate)}`,
description: 'Custom date range',
category: 'date',
})
return suggestions
}
// Check if it's a partial date range (has ..)
if (partialRangePattern.test(partial)) {
const startDate = partial.replace(/\.+$/, '')
suggestions.push({
id: `date-range-hint-${partial}`,
value: `date:${startDate}..`,
label: `${this.formatDateLabel(startDate)} to ...`,
description: 'Type end date (YYYY-MM-DD)',
category: 'date',
})
return suggestions
}
// Check if it's a year only (YYYY)
if (yearPattern.test(partial)) {
suggestions.push({
id: `date-year-${partial}`,
value: `date:${partial}`,
label: `Year ${partial}`,
description: 'All logs from this year',
category: 'date',
})
return suggestions
}
// Check if it's a month only (YYYY-MM)
if (monthPattern.test(partial)) {
const [year, month] = partial.split('-')
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const monthName = monthNames[Number.parseInt(month, 10) - 1] || month
suggestions.push({
id: `date-month-${partial}`,
value: `date:${partial}`,
label: `${monthName} ${year}`,
description: 'All logs from this month',
category: 'date',
})
return suggestions
}
// Check if it's a complete single date
if (fullDatePattern.test(partial)) {
const date = new Date(partial)
if (!Number.isNaN(date.getTime())) {
suggestions.push({
id: `date-single-${partial}`,
value: `date:${partial}`,
label: this.formatDateLabel(partial),
description: 'Single date',
category: 'date',
})
// Also suggest starting a range
suggestions.push({
id: `date-range-start-${partial}`,
value: `date:${partial}..`,
label: `${this.formatDateLabel(partial)} to ...`,
description: 'Start a date range',
category: 'date',
})
}
return suggestions
}
// Check if user is typing a date pattern
if (partialDatePattern.test(partial) && partial.length >= 4) {
suggestions.push({
id: 'date-custom-hint',
value: `date:${partial}`,
label: partial,
description: 'Continue typing: YYYY, YYYY-MM, or YYYY-MM-DD',
category: 'date',
})
}
return suggestions
}
/**
* Format a date string for display
*/
private formatDateLabel(dateStr: string): string {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
/**
* Match filter values across all definitions
*/
private getMatchingFilterValues(query: string): Suggestion[] {
if (!query.trim()) return []
const matches: Suggestion[] = []
const lowerQuery = query.toLowerCase()
for (const filterDef of FILTER_DEFINITIONS) {
for (const option of filterDef.options) {
if (
option.value.toLowerCase().includes(lowerQuery) ||
option.label.toLowerCase().includes(lowerQuery)
) {
matches.push({
id: `filter-match-${filterDef.key}-${option.value}`,
value: `${filterDef.key}:${option.value}`,
label: `${filterDef.label}: ${option.label}`,
description: option.description,
category: filterDef.key as any,
})
}
}
}
return matches.slice(0, 5)
}
/**
* Match triggers by label (core + integrations)
*/
private getMatchingTriggers(query: string): Suggestion[] {
if (!query.trim()) return []
const lowerQuery = query.toLowerCase()
const allTriggers = this.getAllTriggers()
const matches = allTriggers
.filter((trigger) => trigger.label.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
const aLabel = a.label.toLowerCase()
const bLabel = b.label.toLowerCase()
if (aLabel === lowerQuery) return -1
if (bLabel === lowerQuery) return 1
if (aLabel.startsWith(lowerQuery) && !bLabel.startsWith(lowerQuery)) return -1
if (bLabel.startsWith(lowerQuery) && !aLabel.startsWith(lowerQuery)) return 1
return aLabel.localeCompare(bLabel)
})
.slice(0, 8)
.map((trigger) => ({
id: `trigger-match-${trigger.value}`,
value: `trigger:${trigger.value}`,
label: trigger.label,
description: `${trigger.label}-triggered runs`,
category: 'trigger' as const,
color: trigger.color,
}))
return matches
}
/**
* Match workflows by name/description
*/
private getMatchingWorkflows(query: string): Suggestion[] {
if (!query.trim() || this.workflowsData.length === 0) return []
const lowerQuery = query.toLowerCase()
const matches = this.workflowsData
.filter(
(workflow) =>
workflow.name.toLowerCase().includes(lowerQuery) ||
workflow.description?.toLowerCase().includes(lowerQuery)
)
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((workflow) => ({
id: `workflow-match-${workflow.id}`,
value: `workflow:"${workflow.name}"`,
label: workflow.name,
description: workflow.description,
category: 'workflow' as const,
}))
return matches
}
/**
* Match folders by name
*/
private getMatchingFolders(query: string): Suggestion[] {
if (!query.trim() || this.foldersData.length === 0) return []
const lowerQuery = query.toLowerCase()
const matches = this.foldersData
.filter((folder) => folder.name.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((folder) => ({
id: `folder-match-${folder.id}`,
value: `folder:"${folder.name}"`,
label: folder.name,
category: 'folder' as const,
}))
return matches
}
}
+31
View File
@@ -0,0 +1,31 @@
import { db } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
/** Minimal log record returned by server-side service lookups. */
export interface LogRecord {
id: string
workspaceId: string
startedAt: Date
workflowName: string | null
}
/**
* Fetches a log record by its primary key, joining the workflow name.
* Returns null if no matching record exists.
*/
export async function getLogById(id: string): Promise<LogRecord | null> {
const [record] = await db
.select({
id: workflowExecutionLogs.id,
workspaceId: workflowExecutionLogs.workspaceId,
startedAt: workflowExecutionLogs.startedAt,
workflowName: workflow.name,
})
.from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.where(eq(workflowExecutionLogs.id, id))
.limit(1)
return record ?? null
}
+467
View File
@@ -0,0 +1,467 @@
import type { Edge } from 'reactflow'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types'
import type {
BlockTokens,
IterationToolCall,
NormalizedBlockOutput,
ProviderTimingSegment,
} from '@/executor/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
export type { WorkflowState }
export type WorkflowEdge = Edge
export interface PricingInfo {
input: number
output: number
cachedInput?: number
updatedAt: string
}
export interface TokenUsage {
input: number
output: number
total: number
}
export interface CostBreakdown {
input: number
output: number
total: number
tokens: TokenUsage
model: string
pricing: PricingInfo
}
export interface ToolCall {
name: string
duration: number
startTime: string
endTime: string
status: 'success' | 'error'
input?: Record<string, unknown>
output?: Record<string, unknown>
error?: string
}
export type BlockInputData = Record<string, any>
export type BlockOutputData = NormalizedBlockOutput | null
export interface ExecutionEnvironment {
variables: Record<string, string>
workflowId: string
executionId: string
userId: string
workspaceId: string
}
import type { CoreTriggerType } from '@/stores/logs/filters/types'
export interface ExecutionTrigger {
type: CoreTriggerType | string
source: string
data?: Record<string, unknown> & {
correlation?: AsyncExecutionCorrelation
}
timestamp: string
}
export interface ExecutionStatus {
status: 'running' | 'completed' | 'failed' | 'cancelled'
startedAt: string
endedAt?: string
durationMs?: number
}
export const EXECUTION_FINALIZATION_PATHS = [
'completed',
'fallback_completed',
'force_failed',
'cancelled',
'paused',
] as const
export type ExecutionFinalizationPath = (typeof EXECUTION_FINALIZATION_PATHS)[number]
export interface ExecutionLastStartedBlock {
blockId: string
blockName: string
blockType: string
startedAt: string
}
export interface ExecutionLastCompletedBlock {
blockId: string
blockName: string
blockType: string
endedAt: string
success: boolean
}
export interface WorkflowExecutionSnapshot {
id: string
workflowId: string | null
stateHash: string
stateData: WorkflowState
createdAt: string
}
export type WorkflowExecutionSnapshotInsert = Omit<WorkflowExecutionSnapshot, 'createdAt'>
export type WorkflowExecutionSnapshotSelect = WorkflowExecutionSnapshot
export interface WorkflowExecutionLog {
id: string
workflowId: string | null
executionId: string
stateSnapshotId: string
level: 'info' | 'error'
trigger: ExecutionTrigger['type']
startedAt: string
endedAt: string
totalDurationMs: number
files?: Array<{
id: string
name: string
size: number
type: string
url: string
key: string
}>
// Execution details
executionData: {
environment?: ExecutionEnvironment
trigger?: ExecutionTrigger
correlation?: AsyncExecutionCorrelation
error?: string
lastStartedBlock?: ExecutionLastStartedBlock
lastCompletedBlock?: ExecutionLastCompletedBlock
hasTraceSpans?: boolean
traceSpanCount?: number
completionFailure?: string
finalizationPath?: ExecutionFinalizationPath
traceSpans?: TraceSpan[]
tokens?: { input?: number; output?: number; total?: number }
models?: Record<
string,
{
input?: number
output?: number
total?: number
tokens?: { input?: number; output?: number; total?: number }
}
>
executionState?: SerializableExecutionState
executionStateSummary?: {
executedBlockCount: number
blockLogCount: number
completedLoopCount: number
activeExecutionPathLength: number
pendingQueueLength: number
}
executionDataTruncated?: boolean
executionDataOriginalBytes?: number
executionDataStoredBytes?: number
executionDataMaxBytes?: number
executionDataTruncationReason?: string
finalOutput?: any
workflowInput?: unknown
errorDetails?: {
blockId: string
blockName: string
error: string
stackTrace?: string
}
}
// Top-level cost information
cost?: {
input?: number
output?: number
total?: number
tokens?: { input?: number; output?: number; total?: number }
models?: Record<
string,
{
input?: number
output?: number
total?: number
tokens?: { input?: number; output?: number; total?: number }
}
>
}
duration?: string
createdAt: string
}
export type WorkflowExecutionLogInsert = Omit<WorkflowExecutionLog, 'id' | 'createdAt'>
export type WorkflowExecutionLogSelect = WorkflowExecutionLog
export type TokenInfo = BlockTokens
export interface ProviderTiming {
duration: number
startTime: string
endTime: string
segments: ProviderTimingSegment[]
}
export interface TraceSpan {
id: string
name: string
type: string
duration: number
startTime: string
endTime: string
children?: TraceSpan[]
/**
* @deprecated Tool invocations are emitted as `children` with `type: 'tool'`.
* This field only appears on legacy trace spans persisted before the unification.
*/
toolCalls?: ToolCall[]
status?: 'success' | 'error'
/** Whether this block's error was handled by an error handler path */
errorHandled?: boolean
tokens?: TokenInfo
relativeStartMs?: number
blockId?: string
executionOrder?: number
input?: Record<string, unknown>
output?: Record<string, unknown>
childWorkflowSnapshotId?: string
childWorkflowId?: string
model?: string
cost?: {
input?: number
output?: number
total?: number
toolCost?: number
}
providerTiming?: ProviderTiming
loopId?: string
parallelId?: string
iterationIndex?: number
parentIterations?: ParentIteration[]
/**
* For model child spans: the assistant's thinking/reasoning blocks from this
* iteration, stringified. Surfaces Anthropic extended thinking and equivalents.
*/
thinking?: string
/**
* For model child spans: the tool calls the assistant requested in this
* iteration. `id` is the provider-assigned `tool_call.id`, used to correlate
* the following tool child span via its `toolCallId` field.
*/
modelToolCalls?: IterationToolCall[]
/**
* For model child spans: the provider-reported stop reason
* (`stop`, `tool_use`, `length`, …).
*/
finishReason?: string
/**
* For tool child spans: the `tool_call.id` this tool invocation satisfies.
* Matches one of the preceding model child's `modelToolCalls[i].id`.
*/
toolCallId?: string
/**
* For model child spans: time-to-first-token in ms (streaming runs only).
*/
ttft?: number
/**
* For model child spans: the provider system identifier
* (`anthropic`, `openai`, `gemini`, …) — aligns with OTel `gen_ai.system`.
*/
provider?: string
/**
* For failed child spans: structured error class
* (e.g. `rate_limit`, `context_length`).
*/
errorType?: string
/** For failed child spans: human-readable error message. */
errorMessage?: string
}
export interface WorkflowExecutionSummary {
id: string
workflowId: string
workflowName: string
executionId: string
trigger: ExecutionTrigger['type']
status: ExecutionStatus['status']
startedAt: string
endedAt: string
durationMs: number
costSummary: {
total: number
inputCost: number
outputCost: number
tokens: number
}
stateSnapshotId: string
errorSummary?: {
blockId: string
blockName: string
message: string
}
}
export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
environment: ExecutionEnvironment
triggerData: ExecutionTrigger
blockExecutions: BlockExecutionSummary[]
traceSpans: TraceSpan[]
workflowState: WorkflowState
}
export interface BlockExecutionSummary {
id: string
blockId: string
blockName: string
blockType: string
startedAt: string
endedAt: string
durationMs: number
status: 'success' | 'error' | 'skipped'
errorMessage?: string
cost?: CostBreakdown
inputSummary: {
parameterCount: number
hasComplexData: boolean
}
outputSummary: {
hasOutput: boolean
outputType: string
hasError: boolean
}
}
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
hasNext: boolean
hasPrevious: boolean
}
}
export type WorkflowExecutionsResponse = PaginatedResponse<WorkflowExecutionSummary>
export type BlockExecutionsResponse = PaginatedResponse<BlockExecutionSummary>
export interface WorkflowExecutionFilters {
workflowIds?: string[]
folderIds?: string[]
triggers?: ExecutionTrigger['type'][]
status?: ExecutionStatus['status'][]
startDate?: string
endDate?: string
search?: string
minDuration?: number
maxDuration?: number
minCost?: number
maxCost?: number
hasErrors?: boolean
}
export interface PaginationParams {
page: number
pageSize: number
sortBy?: 'startedAt' | 'durationMs' | 'totalCost' | 'blockCount'
sortOrder?: 'asc' | 'desc'
}
export interface LogsQueryParams extends WorkflowExecutionFilters, PaginationParams {
includeBlockSummary?: boolean
includeWorkflowState?: boolean
}
export interface LogsError {
code: 'EXECUTION_NOT_FOUND' | 'SNAPSHOT_NOT_FOUND' | 'INVALID_WORKFLOW_STATE' | 'STORAGE_ERROR'
message: string
details?: Record<string, unknown>
}
export interface ValidationError {
field: string
message: string
value: unknown
}
export class LogsServiceError extends Error {
public code: LogsError['code']
public details?: Record<string, unknown>
constructor(message: string, code: LogsError['code'], details?: Record<string, unknown>) {
super(message)
this.name = 'LogsServiceError'
this.code = code
this.details = details
}
}
export interface DatabaseOperationResult<T> {
success: boolean
data?: T
error?: LogsServiceError
}
export interface BatchInsertResult<T> {
inserted: T[]
failed: Array<{
item: T
error: string
}>
totalAttempted: number
totalSucceeded: number
totalFailed: number
}
export interface SnapshotService {
createSnapshot(workflowId: string, state: WorkflowState): Promise<WorkflowExecutionSnapshot>
getSnapshot(id: string): Promise<WorkflowExecutionSnapshot | null>
computeStateHash(state: WorkflowState): string
cleanupOrphanedSnapshots(olderThanDays: number): Promise<number>
}
export interface SnapshotCreationResult {
snapshot: WorkflowExecutionSnapshot
isNew: boolean
}
export interface ExecutionLoggerService {
startWorkflowExecution(params: {
workflowId: string
workspaceId: string
executionId: string
trigger: ExecutionTrigger
environment: ExecutionEnvironment
workflowState: WorkflowState
}): Promise<{
workflowLog: WorkflowExecutionLog
snapshot: WorkflowExecutionSnapshot
}>
completeWorkflowExecution(params: {
executionId: string
endedAt: string
totalDurationMs: number
costSummary: {
totalCost: number
totalInputCost: number
totalOutputCost: number
totalTokens: number
}
finalOutput: BlockOutputData
traceSpans?: TraceSpan[]
workflowInput?: any
executionState?: SerializableExecutionState
finalizationPath?: ExecutionFinalizationPath
completionFailure?: string
isResume?: boolean
level?: 'info' | 'error'
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
}): Promise<WorkflowExecutionLog>
}