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
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(',')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user