Files
simstudioai--sim/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

338 lines
9.7 KiB
TypeScript

/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
import {
PauseResumeManager,
updateResumeOutputInAggregationBuffers,
} from '@/lib/workflows/executor/human-in-the-loop-manager'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { PausePoint, SerializedSnapshot } from '@/executor/types'
function createExecutionState(): SerializableExecutionState {
return {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
}
describe('updateResumeOutputInAggregationBuffers', () => {
it('replaces a paused parallel branch placeholder with the resumed HITL output', () => {
const pausedOutput = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: 'hitl₍1₎',
},
}
const siblingOutput = { value: 'already-complete' }
const mergedOutput = {
response: { data: { submission: { approved: true } } },
submission: { approved: true },
_resumed: true,
}
const state = createExecutionState()
state.parallelExecutions = {
'parallel-1': {
branchOutputs: {
0: [siblingOutput],
1: [pausedOutput],
},
},
}
updateResumeOutputInAggregationBuffers(
state,
'hitl₍1₎',
'hitl',
'pause-context-1',
mergedOutput
)
expect(state.parallelExecutions['parallel-1'].branchOutputs).toEqual({
0: [siblingOutput],
1: [mergedOutput],
})
})
it('does not replace unrelated paused parallel branch outputs', () => {
const unrelatedPausedOutput = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'different-context',
blockId: 'hitl₍1₎',
},
}
const mergedOutput = {
response: { data: { submission: { approved: true } } },
submission: { approved: true },
_resumed: true,
}
const state = createExecutionState()
state.parallelExecutions = {
'parallel-1': {
branchOutputs: {
1: [unrelatedPausedOutput],
},
},
}
updateResumeOutputInAggregationBuffers(
state,
'hitl₍1₎',
'hitl',
'pause-context-1',
mergedOutput
)
expect(state.parallelExecutions['parallel-1'].branchOutputs).toEqual({
1: [unrelatedPausedOutput],
})
})
it('replaces paused loop iteration outputs using the resumed state block key', () => {
const pausedOutput = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: 'hitl',
},
}
const unrelatedPausedOutput = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'different-context',
blockId: 'hitl',
},
}
const siblingOutput = { value: 'already-complete' }
const mergedOutput = {
response: { data: { submission: { approved: true } } },
submission: { approved: true },
_resumed: true,
}
const state = createExecutionState()
state.loopExecutions = {
'loop-1': {
currentIterationOutputs: {
hitl: pausedOutput,
sibling: siblingOutput,
},
},
'loop-2': {
currentIterationOutputs: {
hitl: unrelatedPausedOutput,
},
},
}
updateResumeOutputInAggregationBuffers(
state,
'hitl₍1₎',
'hitl',
'pause-context-1',
mergedOutput
)
expect(state.loopExecutions['loop-1'].currentIterationOutputs).toEqual({
'hitl₍1₎': mergedOutput,
sibling: siblingOutput,
})
expect(state.loopExecutions['loop-2'].currentIterationOutputs).toEqual({
hitl: unrelatedPausedOutput,
})
})
})
describe('PauseResumeManager.getPauseContextDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('does not duplicate a pause point large response payload between pausePoint and execution.pausePoints', async () => {
const largeDisplayValue = 'x'.repeat(50_000)
const row = {
id: 'paused-exec-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
status: 'paused',
pausedAt: null,
updatedAt: null,
expiresAt: null,
metadata: {},
executionSnapshot: { triggerIds: [] },
pausePoints: {
'ctx-1': {
contextId: 'ctx-1',
blockId: 'hitl-1',
resumeStatus: 'paused',
snapshotReady: true,
pauseKind: 'human',
registeredAt: '2026-07-02T00:00:00.000Z',
response: {
data: {
operation: 'human',
inputFormat: [{ id: 'field_0', name: 'approved', type: 'boolean', required: false }],
submission: null,
responseStructure: [
{ name: 'ai_analysis', type: 'string', value: largeDisplayValue },
],
},
status: 200,
headers: {},
},
},
'ctx-2': {
contextId: 'ctx-2',
blockId: 'hitl-2',
resumeStatus: 'paused',
snapshotReady: true,
pauseKind: 'human',
registeredAt: '2026-07-02T00:00:00.000Z',
response: {
data: { operation: 'human', inputFormat: [], submission: null },
status: 200,
headers: {},
},
},
},
}
dbChainMockFns.limit.mockResolvedValueOnce([row])
dbChainMockFns.orderBy.mockResolvedValueOnce([])
const detail = await PauseResumeManager.getPauseContextDetail({
workflowId: 'workflow-1',
executionId: 'execution-1',
contextId: 'ctx-1',
})
expect(detail).not.toBeNull()
// The requested pause point keeps its full response payload.
expect(detail!.pausePoint.response.data.responseStructure[0].value).toBe(largeDisplayValue)
expect(detail!.pausePoint.contextId).toBe('ctx-1')
// `execution.pausePoints` must not re-embed the (potentially large)
// response payload — it's already available via `pausePoint` above.
for (const point of detail!.execution.pausePoints) {
expect(point.response?.data).toBeUndefined()
}
// Non-payload fields are still present on the execution's pause points.
expect(detail!.execution.pausePoints.map((p) => p.contextId).sort()).toEqual(['ctx-1', 'ctx-2'])
expect(detail!.execution.pausePoints.find((p) => p.contextId === 'ctx-1')?.resumeStatus).toBe(
'paused'
)
})
it('returns null when the pause context no longer exists', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'paused-exec-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
status: 'paused',
pausedAt: null,
updatedAt: null,
expiresAt: null,
metadata: {},
executionSnapshot: { triggerIds: [] },
pausePoints: {
'ctx-1': {
contextId: 'ctx-1',
blockId: 'hitl-1',
resumeStatus: 'paused',
snapshotReady: true,
pauseKind: 'human',
registeredAt: '2026-07-02T00:00:00.000Z',
response: { data: { operation: 'human' }, status: 200, headers: {} },
},
},
},
])
dbChainMockFns.orderBy.mockResolvedValueOnce([])
const detail = await PauseResumeManager.getPauseContextDetail({
workflowId: 'workflow-1',
executionId: 'execution-1',
contextId: 'missing-ctx',
})
expect(detail).toBeNull()
})
})
describe('PauseResumeManager.persistPauseResult metadata merge on re-pause', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('preserves the stashed cellContext when an existing paused row re-pauses (chained waits)', async () => {
const cellContext = {
tableId: 'table-1',
rowId: 'row-1',
workspaceId: 'workspace-1',
groupId: 'group-1',
workflowId: 'workflow-1',
}
const existingRow = {
id: 'paused-exec-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
status: 'partially_resumed',
pausePoints: {
'ctx-wait-1': { contextId: 'ctx-wait-1', blockId: 'wait1', resumeStatus: 'resuming' },
},
metadata: {
pauseScope: 'execution',
triggerIds: ['start'],
executorUserId: 'user-1',
cellContext,
},
}
// First `.limit(1)` resolves the select-for-update to the existing row,
// forcing persistPauseResult down the update (not insert) branch.
dbChainMockFns.limit.mockResolvedValueOnce([existingRow])
const snapshotSeed: SerializedSnapshot = { snapshot: '{}', triggerIds: [] }
const pausePoints: PausePoint[] = [
{
contextId: 'ctx-wait-2',
blockId: 'wait2',
pauseKind: 'time',
resumeAt: new Date(Date.now() + 60_000).toISOString(),
resumeStatus: 'paused',
} as PausePoint,
]
await PauseResumeManager.persistPauseResult({
workflowId: 'workflow-1',
executionId: 'execution-1',
pausePoints,
snapshotSeed,
executorUserId: 'user-1',
})
const updateSetCall = dbChainMockFns.set.mock.calls.find(
([arg]) => arg && typeof arg === 'object' && 'metadata' in (arg as Record<string, unknown>)
)
expect(updateSetCall).toBeDefined()
const updatedMetadata = (updateSetCall![0] as { metadata: Record<string, unknown> }).metadata
expect(updatedMetadata.cellContext).toEqual(cellContext)
expect(updatedMetadata.pauseScope).toBe('execution')
expect(updatedMetadata.executorUserId).toBe('user-1')
})
})