d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
/**
|
||
* @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)
|
||
})
|
||
})
|
||
})
|