|
5 | 5 | queueTableRows, |
6 | 6 | resetDbChainMock, |
7 | 7 | } from '@sim/testing' |
| 8 | +import { isPlainRecord } from '@sim/utils/object' |
8 | 9 | import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' |
9 | 10 | import { recordUsage } from '@/lib/billing/core/usage-log' |
10 | 11 | import { ExecutionLogger } from '@/lib/logs/execution/logger' |
@@ -288,6 +289,109 @@ describe('ExecutionLogger', () => { |
288 | 289 | expect(emitExecutionCompletedEvent).not.toHaveBeenCalled() |
289 | 290 | }) |
290 | 291 |
|
| 292 | + const EMPTY_STATE = { |
| 293 | + blockStates: {}, |
| 294 | + executedBlocks: [], |
| 295 | + blockLogs: [], |
| 296 | + decisions: { router: {}, condition: {} }, |
| 297 | + completedLoops: [], |
| 298 | + activeExecutionPath: [], |
| 299 | + } |
| 300 | + const RUN_PROVENANCE = { version: 1, complete: true, entries: [] } |
| 301 | + |
| 302 | + /** |
| 303 | + * Drives a real completion and returns the `execution_data` actually written. |
| 304 | + * `redactedState` stands in for the PII pass, which either hands back a |
| 305 | + * redacted state or none at all. |
| 306 | + */ |
| 307 | + async function completeAndReadWrite(params: { |
| 308 | + executionState?: SerializableExecutionState |
| 309 | + redactedState?: SerializableExecutionState |
| 310 | + }) { |
| 311 | + const startedAt = new Date('2026-08-11T00:00:00.000Z') |
| 312 | + queueTableRows(workflowExecutionLogs, [ |
| 313 | + { |
| 314 | + id: 'log-1', |
| 315 | + workflowId: 'workflow-1', |
| 316 | + workspaceId: 'workspace-1', |
| 317 | + executionId: 'execution-1', |
| 318 | + stateSnapshotId: 'snapshot-1', |
| 319 | + level: 'info', |
| 320 | + status: 'running', |
| 321 | + trigger: 'api', |
| 322 | + startedAt, |
| 323 | + endedAt: null, |
| 324 | + totalDurationMs: null, |
| 325 | + executionData: {}, |
| 326 | + createdAt: startedAt, |
| 327 | + }, |
| 328 | + ]) |
| 329 | + dbChainMockFns.returning.mockResolvedValueOnce([ |
| 330 | + { id: 'log-1', executionData: {}, startedAt, createdAt: startedAt }, |
| 331 | + ]) |
| 332 | + const internals = logger as unknown as { |
| 333 | + applyPiiRedaction: (workspaceId: string, payload: Record<string, unknown>) => unknown |
| 334 | + recordExecutionUsage: () => Promise<number> |
| 335 | + } |
| 336 | + vi.spyOn(internals, 'applyPiiRedaction').mockImplementation( |
| 337 | + async (_workspaceId: string, payload: Record<string, unknown>) => |
| 338 | + Object.hasOwn(params, 'redactedState') |
| 339 | + ? { ...payload, executionState: params.redactedState } |
| 340 | + : payload |
| 341 | + ) |
| 342 | + vi.spyOn(internals, 'recordExecutionUsage').mockResolvedValue(0) |
| 343 | + |
| 344 | + await logger.completeWorkflowExecution({ |
| 345 | + executionId: 'execution-1', |
| 346 | + endedAt: '2026-08-11T00:00:02.000Z', |
| 347 | + totalDurationMs: 2000, |
| 348 | + costSummary: { |
| 349 | + totalCost: 0, |
| 350 | + totalInputCost: 0, |
| 351 | + totalOutputCost: 0, |
| 352 | + totalTokens: 0, |
| 353 | + totalPromptTokens: 0, |
| 354 | + totalCompletionTokens: 0, |
| 355 | + baseExecutionCharge: 0, |
| 356 | + models: {}, |
| 357 | + }, |
| 358 | + finalOutput: { completed: true }, |
| 359 | + traceSpans: [], |
| 360 | + ...(params.executionState ? { executionState: params.executionState } : {}), |
| 361 | + }) |
| 362 | + |
| 363 | + return dbChainMockFns.set.mock.calls |
| 364 | + .map(([values]: [{ executionData?: unknown }]) => values?.executionData) |
| 365 | + .find((data): data is Record<string, unknown> => isPlainRecord(data)) |
| 366 | + } |
| 367 | + |
| 368 | + /** |
| 369 | + * The display projection rebuilds its redaction registry from this key. |
| 370 | + * Compaction drops `executionState`, so the run provenance has to reach the |
| 371 | + * row independently of it or truncated runs render as an empty trace. |
| 372 | + */ |
| 373 | + test.each([ |
| 374 | + ['redaction preserves the state', EMPTY_STATE], |
| 375 | + ['redaction drops the state entirely', undefined], |
| 376 | + ])('lifts run provenance onto the top-level key when %s', async (_case, redactedState) => { |
| 377 | + const written = await completeAndReadWrite({ |
| 378 | + executionState: { |
| 379 | + ...EMPTY_STATE, |
| 380 | + resolvedSecretTraceProvenance: RUN_PROVENANCE, |
| 381 | + } as unknown as SerializableExecutionState, |
| 382 | + redactedState: redactedState as SerializableExecutionState | undefined, |
| 383 | + }) |
| 384 | + |
| 385 | + expect(written?.resolvedSecretTraceProvenance).toEqual(RUN_PROVENANCE) |
| 386 | + }) |
| 387 | + |
| 388 | + test('omits the provenance key when the run carried none', async () => { |
| 389 | + const written = await completeAndReadWrite({}) |
| 390 | + |
| 391 | + expect(written).toBeDefined() |
| 392 | + expect(written).not.toHaveProperty('resolvedSecretTraceProvenance') |
| 393 | + }) |
| 394 | + |
291 | 395 | test('preserves correlation and diagnostics when execution completes', () => { |
292 | 396 | const loggerInstance = new ExecutionLogger() as any |
293 | 397 |
|
@@ -628,6 +732,84 @@ describe('ExecutionLogger', () => { |
628 | 732 | expect(compacted.traceSpans?.[0]?.toolCalls?.[0]).not.toHaveProperty('output') |
629 | 733 | expect(compacted.traceSpans?.[0]?.toolCalls?.[0]).not.toHaveProperty('error') |
630 | 734 | }) |
| 735 | + |
| 736 | + const PROVENANCE = { version: 1, complete: true, entries: [] } as const |
| 737 | + |
| 738 | + function buildSpans(spanCount: number, ioBytes: number) { |
| 739 | + const payload = 'x'.repeat(ioBytes) |
| 740 | + return Array.from({ length: spanCount }, (_unused, index) => ({ |
| 741 | + id: `span-${index}`, |
| 742 | + name: `Block ${index}`, |
| 743 | + type: 'function', |
| 744 | + duration: 1, |
| 745 | + startTime: '2025-01-01T00:00:00.000Z', |
| 746 | + endTime: '2025-01-01T00:00:01.000Z', |
| 747 | + status: 'success' as const, |
| 748 | + output: { data: payload }, |
| 749 | + })) |
| 750 | + } |
| 751 | + |
| 752 | + function compactWithProvenance(traceSpans: unknown[], finalOutput: unknown) { |
| 753 | + const loggerInstance = new ExecutionLogger() as unknown as { |
| 754 | + compactExecutionDataForStorage: ( |
| 755 | + data: Record<string, unknown>, |
| 756 | + executionId: string |
| 757 | + ) => Record<string, unknown> |
| 758 | + } |
| 759 | + return loggerInstance.compactExecutionDataForStorage( |
| 760 | + { |
| 761 | + secretProjectionVersion: SECRET_PROJECTION_VERSION, |
| 762 | + resolvedSecretTraceProvenance: PROVENANCE, |
| 763 | + hasTraceSpans: true, |
| 764 | + traceSpanCount: traceSpans.length, |
| 765 | + finalOutput, |
| 766 | + executionState: { |
| 767 | + blockStates: {}, |
| 768 | + executedBlocks: [], |
| 769 | + blockLogs: [], |
| 770 | + decisions: { router: {}, condition: {} }, |
| 771 | + completedLoops: [], |
| 772 | + activeExecutionPath: [], |
| 773 | + resolvedSecretTraceProvenance: PROVENANCE, |
| 774 | + }, |
| 775 | + traceSpans, |
| 776 | + }, |
| 777 | + 'execution-provenance' |
| 778 | + ) |
| 779 | + } |
| 780 | + |
| 781 | + test('preserves run provenance through the summarized compaction tier', () => { |
| 782 | + // One oversized value: summarization alone brings the row under the cap. |
| 783 | + const compacted = compactWithProvenance(buildSpans(1, 4 * 1024 * 1024), { |
| 784 | + data: 'x'.repeat(4 * 1024 * 1024), |
| 785 | + }) |
| 786 | + |
| 787 | + expect(compacted.executionDataTruncated).toBe(true) |
| 788 | + expect(compacted.executionDataTruncationReason).toContain('were summarized') |
| 789 | + expect(compacted.executionState).toBeUndefined() |
| 790 | + expect(compacted.resolvedSecretTraceProvenance).toEqual(PROVENANCE) |
| 791 | + }) |
| 792 | + |
| 793 | + test('drops run provenance from the metadata-only tier, which stores no spans', () => { |
| 794 | + // That tier keeps no traceSpans, so provenance there buys nothing and |
| 795 | + // would put an unbounded value in the last-resort size floor. |
| 796 | + const compacted = compactWithProvenance(buildSpans(20_000, 8), {}) |
| 797 | + |
| 798 | + expect(compacted.executionDataTruncationReason).toContain('only execution metadata') |
| 799 | + expect(compacted.traceSpans).toBeUndefined() |
| 800 | + expect(compacted.resolvedSecretTraceProvenance).toBeUndefined() |
| 801 | + }) |
| 802 | + |
| 803 | + test('preserves run provenance through the minimal compaction tier', () => { |
| 804 | + // Many spans whose IO each sits under MAX_TRACE_IO_BYTES survive |
| 805 | + // summarization, so only the IO-stripping minimal tier fits the cap. |
| 806 | + const compacted = compactWithProvenance(buildSpans(1200, 4 * 1024), {}) |
| 807 | + |
| 808 | + expect(compacted.executionDataTruncated).toBe(true) |
| 809 | + expect(compacted.executionDataTruncationReason).toContain('details were omitted') |
| 810 | + expect(compacted.executionState).toBeUndefined() |
| 811 | + expect(compacted.resolvedSecretTraceProvenance).toEqual(PROVENANCE) |
| 812 | + }) |
631 | 813 | }) |
632 | 814 |
|
633 | 815 | describe('file extraction', () => { |
|
0 commit comments