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
338 lines
9.7 KiB
TypeScript
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')
|
|
})
|
|
})
|