mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-17 22:20:19 +00:00
5eb1b94138
* chore: initialize upstream-based workflow orchestration v2 draft * feat(workflow): validate declarative edge orchestration * feat(workflow): evaluate edge conditions safely * feat(workflow): support declarative condition operators * feat(workflow): evaluate edge route decisions * feat(workflow): skip unmatched success branches * feat(workflow): route failed node outcomes * feat(workflow): validate node join mode * feat(workflow): support all and any joins * feat(workflow): schedule nodes on completion * feat(workflow): store append-only edge evidence * feat(workflow): persist edge decisions fail closed * feat(workflow): expose edge evidence in run history * feat(workflow): validate bounded feedback edges * feat(workflow): compile single-entry natural loops * feat(workflow): validate laminar nested loops * feat(workflow): fail closed during graph preflight * feat(workflow): preflight reruns and freeze loops * feat(workflow): identify loop execution instances * feat(workflow): execute bounded top-level loops * feat(workflow): audit conditional loop exits * feat(workflow): record loop iteration limits * feat(workflow): identify edge iteration evidence * feat(workflow): audit loop forward edges * feat(workflow): persist loop epoch evidence * feat(workflow): expose loop epochs in run history * feat(workflow): record failed loop epochs * feat(workflow): audit canceled loop epochs * feat(workflow): audit loop agent timeouts * feat(workflow): gate loop executions on approval * feat(workflow): enforce run execution budget * feat(workflow): enforce loop run deadline * feat(workflow): bound loop approval waits * feat(workflow): enforce DAG run deadline * feat(workflow): enforce rerun deadline * feat(workflow): guard rerun execution budget * feat(workflow): run one downstream node after loop exit * feat(workflow): dispatch persisted loop exit targets * feat(workflow): honor conditional routing inside loop bodies * feat(workflow): continue through post-loop DAGs * feat(workflow): route post-loop failures * feat(workflow): add secure definition portability * feat(workflow): expose portability client API * feat(workflow): add import and export controls * feat(workflow): execute disjoint and nested loops * feat(workflow): edit edge orchestration from canvas * feat(workflow): show execution evidence in run history * feat(workflow): edit node join policy * feat(workflow): create connected nodes atomically * test(workflow): cover orchestration canvas in browser * refactor(workflow): unify laminar loop runtime * fix(workflow): bind approvals to executions * test(workflow): verify canvas interactions in browser * feat(workflow): enforce execution identity policies * feat(workflow): harden lifecycle recovery * refactor(workflow): share fresh and rerun schedulers * feat(workflow): harden orchestration evidence and portability * fix(workflow): close final acceptance and canvas gaps * test(workflow): add disposable live acceptance * fix(workflow): migrate legacy execution evidence * refactor(workflow): use icons for import and export * refactor(workflow): use ctrl-z for canvas undo * refactor(workflow): restore profile-default agent context * fix(workflow): strip legacy policy from edge node copies * feat(workflow): guide ordinary users through routing * refactor(workflow): move field help into tooltips * feat(workflow): validate typed condition values * feat(chat): align maximum reasoning effort * test(workflow): isolate manager SQLite runtime Keep the WorkflowManager suite on a disposable SQLite directory so coverage workers cannot race the shared test-runtime database. Production and development database paths are unchanged. * fix(workflow): keep Hermes API mode profile-owned * fix(workflow): make imports portable across environments Strip source runtime bindings from exports and legacy imports while keeping capability and skill checks at execution time. Address runtime subscription, feedback routing, approval cancellation, durable Session evidence, SQLite index migration, and compact evidence rendering. * fix(bridge): reject unsupported reasoning effort Keep Hermes API mode profile-owned while restoring explicit failure when the runtime cannot parse or apply a requested reasoning effort. * test(workflow): use writable Session fixture workspace * fix(workflow): align import export icon semantics * feat(workflow): make execution details user friendly * fix workflow replay and presentation regressions --------- Co-authored-by: Lux <wangw9475@agent.qq.com> Co-authored-by: ekko <fqsy1416@gmail.com>
406 lines
20 KiB
TypeScript
406 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const managerMock = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
deleteRun: vi.fn(),
|
|
rerunFromNode: vi.fn(),
|
|
runNow: vi.fn(),
|
|
stopRun: vi.fn(),
|
|
approveNode: vi.fn(),
|
|
create: vi.fn(),
|
|
}))
|
|
const listWorkflowRunsWithEvidenceMock = vi.hoisted(() => vi.fn())
|
|
const getWorkflowRunWithEvidenceMock = vi.hoisted(() => vi.fn())
|
|
const preflightExecutionMock = vi.hoisted(() => vi.fn())
|
|
const preflightRerunMock = vi.hoisted(() => vi.fn())
|
|
const skillDependenciesMock = vi.hoisted(() => vi.fn())
|
|
const listWorkflowRunNodeSessionsMock = vi.hoisted(() => vi.fn())
|
|
const listWorkflowRunEdgeEvaluationsMock = vi.hoisted(() => vi.fn())
|
|
const listWorkflowRunLoopEpochsMock = vi.hoisted(() => vi.fn())
|
|
const listUserProfilesMock = vi.hoisted(() => vi.fn())
|
|
const getAvailableModelGroupsMock = vi.hoisted(() => vi.fn())
|
|
const assertImportCapabilitiesMock = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('../../packages/server/src/services/workflow-manager', async importOriginal => {
|
|
const actual = await importOriginal<typeof import('../../packages/server/src/services/workflow-manager')>()
|
|
return {
|
|
...actual,
|
|
getWorkflowManager: () => managerMock,
|
|
preflightWorkflowExecutionDefinition: preflightExecutionMock,
|
|
preflightWorkflowRerunDefinition: preflightRerunMock,
|
|
assertWorkflowNodeSkillDependencies: skillDependenciesMock,
|
|
}
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
|
listUserProfiles: listUserProfilesMock,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/workflow-import-capabilities', async importOriginal => {
|
|
const actual = await importOriginal<typeof import('../../packages/server/src/services/workflow-import-capabilities')>()
|
|
return {
|
|
...actual,
|
|
assertWorkflowImportCapabilities: assertImportCapabilitiesMock,
|
|
workflowImportEnvironmentRevision: () => 'rev-1',
|
|
}
|
|
})
|
|
|
|
|
|
vi.mock('../../packages/server/src/controllers/hermes/models', () => ({
|
|
getAvailableModelGroupsForProfile: getAvailableModelGroupsMock,
|
|
}))
|
|
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/workflow-run-store', () => ({
|
|
listWorkflowRunNodeSessions: listWorkflowRunNodeSessionsMock,
|
|
listWorkflowRunEdgeEvaluations: listWorkflowRunEdgeEvaluationsMock,
|
|
listWorkflowRunLoopEpochs: listWorkflowRunLoopEpochsMock,
|
|
listWorkflowRunsWithEvidence: listWorkflowRunsWithEvidenceMock,
|
|
getWorkflowRunWithEvidence: getWorkflowRunWithEvidenceMock,
|
|
}))
|
|
|
|
function ctx(overrides: Record<string, any> = {}) {
|
|
return {
|
|
params: {},
|
|
query: {},
|
|
request: { body: {} },
|
|
state: {},
|
|
status: 200,
|
|
body: undefined,
|
|
...overrides,
|
|
} as any
|
|
}
|
|
|
|
describe('workflow controller', () => {
|
|
beforeEach(() => {
|
|
managerMock.get.mockReset()
|
|
managerMock.deleteRun.mockReset()
|
|
managerMock.rerunFromNode.mockReset()
|
|
managerMock.runNow.mockReset()
|
|
managerMock.stopRun.mockReset()
|
|
managerMock.approveNode.mockReset()
|
|
managerMock.create.mockReset()
|
|
listWorkflowRunNodeSessionsMock.mockReset()
|
|
listWorkflowRunEdgeEvaluationsMock.mockReset()
|
|
listWorkflowRunLoopEpochsMock.mockReset()
|
|
listWorkflowRunLoopEpochsMock.mockReturnValue([])
|
|
listWorkflowRunsWithEvidenceMock.mockReset()
|
|
getWorkflowRunWithEvidenceMock.mockReset()
|
|
preflightExecutionMock.mockReset().mockResolvedValue({ compiled: { nodes: [], edges: [], loops: [], startNodeIds: [] }, activeNodeIds: new Set(), activeNodes: [] })
|
|
preflightRerunMock.mockReset().mockResolvedValue({ compiled: { nodes: [], edges: [], loops: [], startNodeIds: [] }, activeNodeIds: new Set(), activeNodes: [] })
|
|
skillDependenciesMock.mockReset().mockResolvedValue(undefined)
|
|
listUserProfilesMock.mockReset()
|
|
listUserProfilesMock.mockReturnValue([])
|
|
getAvailableModelGroupsMock.mockReset().mockResolvedValue([])
|
|
assertImportCapabilitiesMock.mockReset()
|
|
})
|
|
|
|
it('exports, previews, and confirms a portable workflow copy', async () => {
|
|
const source = {
|
|
id: 'workflow-1', name: 'Portable', profile: 'default', workspace: '/private',
|
|
nodes: [{ id: 'n1', type: 'agent', position: { x: 0, y: 0 }, data: { title: 'One', agent: 'hermes', input: 'go' } }],
|
|
edges: [], viewport: null, created_at: 1, updated_at: 2,
|
|
}
|
|
managerMock.get.mockReturnValue(source)
|
|
managerMock.create.mockImplementation((input: any) => ({ id: 'workflow-copy', workspace: '/generated', created_at: 3, updated_at: 3, ...input }))
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const exportCtx = ctx({ params: { id: 'workflow-1' }, state: { user: { id: 'u1', role: 'super_admin' } } })
|
|
await mod.exportDefinition(exportCtx)
|
|
expect(exportCtx.body).toMatchObject({ format: 'hermes-studio.workflow', version: 1, definition: { name: 'Portable' } })
|
|
expect(JSON.stringify(exportCtx.body)).not.toContain('/private')
|
|
|
|
const previewCtx = ctx({ request: { body: { document: JSON.stringify(exportCtx.body), profile: 'default' } }, state: { user: { id: 'u1', role: 'super_admin' } } })
|
|
await mod.previewImport(previewCtx)
|
|
expect(previewCtx.body).toMatchObject({ ok: true, preview: { summary: { name: 'Portable', nodes: 1, edges: 0 } } })
|
|
|
|
const confirmCtx = ctx({ request: { body: { token: previewCtx.body.preview.token, profile: 'default' } }, state: { user: { id: 'u1', role: 'super_admin' } } })
|
|
await mod.confirmImport(confirmCtx)
|
|
expect(confirmCtx.status).toBe(201)
|
|
expect(managerMock.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Portable', profile: 'default' }))
|
|
expect(confirmCtx.body).toMatchObject({ ok: true, workflow: { id: 'workflow-copy', name: 'Portable' } })
|
|
})
|
|
|
|
|
|
it('imports legacy v1 workflows without source-environment capability or skill validation', async () => {
|
|
const legacy = {
|
|
format: 'hermes-studio.workflow', version: 1, definition: {
|
|
name: 'Cross environment',
|
|
nodes: [{ id: 'n1', type: 'agent', position: { x: 0, y: 0 }, data: {
|
|
title: 'One', agent: 'hermes', provider: 'custom:source-only', model: 'source-model',
|
|
apiMode: 'chat_completions', reasoningEffort: 'max', input: 'go', skills: ['source-only-skill'],
|
|
} }],
|
|
edges: [], viewport: null,
|
|
},
|
|
}
|
|
managerMock.create.mockImplementation((input: any) => ({ id: 'workflow-copy', workspace: null, created_at: 3, updated_at: 3, ...input }))
|
|
assertImportCapabilitiesMock.mockImplementation(() => { throw Object.assign(new Error('target capability is unavailable'), { status: 409 }) })
|
|
skillDependenciesMock.mockRejectedValue(Object.assign(new Error('skill unavailable'), { status: 409 }))
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const previewCtx = ctx({ request: { body: { document: JSON.stringify(legacy), profile: 'target' } }, state: { user: { id: 'u1', role: 'super_admin' } } })
|
|
await mod.previewImport(previewCtx)
|
|
expect(previewCtx.status).toBe(200)
|
|
expect(previewCtx.body).toMatchObject({ ok: true, preview: { summary: { name: 'Cross environment' } } })
|
|
|
|
const confirmCtx = ctx({ request: { body: { token: previewCtx.body.preview.token, profile: 'target' } }, state: { user: { id: 'u1', role: 'super_admin' } } })
|
|
await mod.confirmImport(confirmCtx)
|
|
expect(confirmCtx.status).toBe(201)
|
|
expect(managerMock.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
name: 'Cross environment', profile: 'target',
|
|
nodes: [expect.objectContaining({ data: expect.objectContaining({ skills: ['source-only-skill'] }) })],
|
|
}))
|
|
const importedData = managerMock.create.mock.calls[0][0].nodes[0].data
|
|
expect(importedData).not.toHaveProperty('provider')
|
|
expect(importedData).not.toHaveProperty('model')
|
|
expect(importedData).not.toHaveProperty('apiMode')
|
|
expect(importedData).not.toHaveProperty('reasoningEffort')
|
|
expect(assertImportCapabilitiesMock).not.toHaveBeenCalled()
|
|
expect(skillDependenciesMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('lists run records for a workflow', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
listWorkflowRunsWithEvidenceMock.mockReturnValue([{
|
|
id: 'run-1', workflow_id: 'workflow-1', status: 'completed',
|
|
node_sessions: [{ id: 'node-session-1', node_id: 'node-1', status: 'completed' }],
|
|
edge_evaluations: [{ id: 'edge-eval-1', edge_id: 'edge-1', sequence: 0, status: 'taken' }],
|
|
loop_epochs: [{ id: 'loop-epoch-1', loop_id: 'loop:retry', iteration: 0, status: 'completed' }],
|
|
}])
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1' }, query: { limit: '25' } })
|
|
|
|
await mod.listRuns(c)
|
|
|
|
expect(listWorkflowRunsWithEvidenceMock).toHaveBeenCalledWith('workflow-1', 25)
|
|
expect(c.body).toEqual({
|
|
runs: [{
|
|
id: 'run-1',
|
|
workflow_id: 'workflow-1',
|
|
status: 'completed',
|
|
node_sessions: [{ id: 'node-session-1', node_id: 'node-1', status: 'completed' }],
|
|
edge_evaluations: [{ id: 'edge-eval-1', edge_id: 'edge-1', sequence: 0, status: 'taken' }],
|
|
loop_epochs: [{ id: 'loop-epoch-1', loop_id: 'loop:retry', iteration: 0, status: 'completed' }],
|
|
}],
|
|
})
|
|
})
|
|
|
|
it('does not return a partial run history when edge evidence loading fails', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
listWorkflowRunsWithEvidenceMock.mockImplementation(() => { throw new Error('edge evidence read failed') })
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1' } })
|
|
await expect(mod.listRuns(c)).rejects.toThrow('edge evidence read failed')
|
|
expect(c.body).toBeUndefined()
|
|
})
|
|
|
|
it('does not return a partial run history when loop epoch loading fails', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
listWorkflowRunsWithEvidenceMock.mockImplementation(() => { throw new Error('loop epoch read failed') })
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1' } })
|
|
await expect(mod.listRuns(c)).rejects.toThrow('loop epoch read failed')
|
|
expect(c.body).toBeUndefined()
|
|
})
|
|
|
|
it('runs a workflow through the workflow manager', async () => {
|
|
const user = { id: 'user-1', role: 'super_admin' }
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.runNow.mockImplementation(async (_id: string, input: any) => { const run = { id: 'run-1', status: 'running' }; input.onAccepted?.(run); return { run: { ...run, status: 'completed' }, nodeSessions: [] } })
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({
|
|
params: { id: 'workflow-1' },
|
|
request: { body: { start_node_ids: ['node-1', 12, 'node-2'], input: 'go', timeout_ms: '1000' } },
|
|
state: { user },
|
|
})
|
|
|
|
await mod.runNow(c)
|
|
|
|
expect(managerMock.runNow).toHaveBeenCalledWith('workflow-1', expect.objectContaining({
|
|
profile: 'default',
|
|
user,
|
|
startNodeIds: ['node-1', 'node-2'],
|
|
input: 'go',
|
|
timeoutMs: 1000,
|
|
onAccepted: expect.any(Function),
|
|
}))
|
|
expect(c.status).toBe(202)
|
|
expect(c.body).toEqual({ ok: true, status: 'accepted' })
|
|
})
|
|
|
|
|
|
it('fails closed when a fresh execution resolves before durable acceptance', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.runNow.mockResolvedValue({ run: { id: 'run-1', status: 'completed' }, nodeSessions: [] })
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1' } })
|
|
|
|
await mod.runNow(c)
|
|
|
|
expect(c.status).toBe(500)
|
|
expect(c.body).toEqual({ error: 'workflow execution completed before durable acceptance' })
|
|
})
|
|
|
|
it('returns a fresh execution failure instead of reporting acceptance', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.runNow.mockRejectedValue(new Error('workflow run persistence failed'))
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1' } })
|
|
|
|
await mod.runNow(c)
|
|
|
|
expect(c.status).toBe(400)
|
|
expect(c.body).toEqual({ error: 'workflow run persistence failed' })
|
|
})
|
|
|
|
it('returns one run detail with the same Node, Edge, and Loop evidence contract', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
getWorkflowRunWithEvidenceMock.mockReturnValue({
|
|
id: 'run-1', workflow_id: 'workflow-1', status: 'completed',
|
|
node_sessions: [{ id: 'node-session-1', sequence: 1 }],
|
|
edge_evaluations: [{ id: 'edge-eval-1', sequence: 2 }],
|
|
loop_epochs: [{ id: 'loop-epoch-1', sequence: 3 }],
|
|
})
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1', runId: 'run-1' } })
|
|
await mod.getRun(c)
|
|
expect(c.body).toEqual({ run: expect.objectContaining({
|
|
id: 'run-1',
|
|
node_sessions: [{ id: 'node-session-1', sequence: 1 }],
|
|
edge_evaluations: [{ id: 'edge-eval-1', sequence: 2 }],
|
|
loop_epochs: [{ id: 'loop-epoch-1', sequence: 3 }],
|
|
}) })
|
|
})
|
|
|
|
it('stops a workflow run through the workflow manager', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.stopRun.mockResolvedValue({ id: 'run-1', workflow_id: 'workflow-1', status: 'canceled' })
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1', runId: 'run-1' } })
|
|
|
|
await mod.stopRun(c)
|
|
|
|
expect(managerMock.stopRun).toHaveBeenCalledWith('workflow-1', 'run-1', 'Workflow run canceled by user')
|
|
expect(c.body).toEqual({
|
|
ok: true,
|
|
run: { id: 'run-1', workflow_id: 'workflow-1', status: 'canceled' },
|
|
})
|
|
})
|
|
|
|
it('reruns a workflow run from a node through the workflow manager', async () => {
|
|
const user = { id: 'user-1', role: 'super_admin' }
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.rerunFromNode.mockImplementation(async (_wid: string, _rid: string, _nid: string, input: any) => { const run = { id: 'run-1', status: 'running' }; input.onAccepted?.(run); return { run: { ...run, status: 'completed' }, nodeSessions: [] } })
|
|
getWorkflowRunWithEvidenceMock.mockReturnValue({ id: 'run-1', workflow_id: 'workflow-1', profile: 'default', status: 'completed', snapshot_nodes: [], snapshot_edges: [], node_sessions: [], edge_evaluations: [], loop_epochs: [] })
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({
|
|
params: { id: 'workflow-1', runId: 'run-1' },
|
|
request: { body: { node_id: 'node-2', preserve_start_node: true, timeout_ms: '1000' } },
|
|
state: { user },
|
|
})
|
|
|
|
await mod.rerunFromNode(c)
|
|
|
|
expect(managerMock.rerunFromNode).toHaveBeenCalledWith('workflow-1', 'run-1', 'node-2', expect.objectContaining({
|
|
profile: 'default',
|
|
user,
|
|
preserveStartNode: true,
|
|
timeoutMs: 1000,
|
|
onAccepted: expect.any(Function),
|
|
}))
|
|
expect(c.status).toBe(202)
|
|
expect(c.body).toEqual({ ok: true, status: 'accepted' })
|
|
})
|
|
|
|
it('fails closed when a rerun resolves before durable acceptance', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.rerunFromNode.mockResolvedValue({ run: { id: 'run-1', status: 'completed' }, nodeSessions: [] })
|
|
getWorkflowRunWithEvidenceMock.mockReturnValue({ id: 'run-1', workflow_id: 'workflow-1', profile: 'default', status: 'completed', snapshot_nodes: [], snapshot_edges: [], node_sessions: [], edge_evaluations: [], loop_epochs: [] })
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1', runId: 'run-1' }, request: { body: { node_id: 'node-1' } } })
|
|
|
|
await mod.rerunFromNode(c)
|
|
|
|
expect(c.status).toBe(500)
|
|
expect(c.body).toEqual({ error: 'workflow rerun completed before durable acceptance' })
|
|
})
|
|
|
|
it('returns a rerun failure instead of reporting acceptance', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.rerunFromNode.mockRejectedValue(new Error('workflow rerun reset failed'))
|
|
getWorkflowRunWithEvidenceMock.mockReturnValue({ id: 'run-1', workflow_id: 'workflow-1', profile: 'default', status: 'completed', snapshot_nodes: [], snapshot_edges: [], node_sessions: [], edge_evaluations: [], loop_epochs: [] })
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1', runId: 'run-1' }, request: { body: { node_id: 'node-1' } } })
|
|
|
|
await mod.rerunFromNode(c)
|
|
|
|
expect(c.status).toBe(400)
|
|
expect(c.body).toEqual({ error: 'workflow rerun reset failed' })
|
|
})
|
|
|
|
it('deletes a workflow run through the workflow manager', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.deleteRun.mockResolvedValue(true)
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({ params: { id: 'workflow-1', runId: 'run-1' } })
|
|
|
|
await mod.deleteRun(c)
|
|
|
|
expect(managerMock.deleteRun).toHaveBeenCalledWith('workflow-1', 'run-1')
|
|
expect(c.body).toEqual({ ok: true })
|
|
})
|
|
|
|
it('approves a pending workflow node through the workflow manager', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.approveNode.mockReturnValue(true)
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({
|
|
params: { id: 'workflow-1', runId: 'run-1', nodeId: 'node-1' },
|
|
request: { body: { approved: true } },
|
|
})
|
|
|
|
await mod.approveNode(c)
|
|
|
|
expect(managerMock.approveNode).toHaveBeenCalledWith('workflow-1', 'run-1', 'node-1', true, undefined)
|
|
expect(managerMock.stopRun).not.toHaveBeenCalled()
|
|
expect(c.body).toEqual({ ok: true })
|
|
})
|
|
|
|
it('records a workflow node rejection without stopping the run immediately', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'default', nodes: [], edges: [] })
|
|
managerMock.approveNode.mockReturnValue(true)
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({
|
|
params: { id: 'workflow-1', runId: 'run-1', nodeId: 'node-1' },
|
|
request: { body: { approved: false } },
|
|
})
|
|
|
|
await mod.approveNode(c)
|
|
|
|
expect(managerMock.approveNode).toHaveBeenCalledWith('workflow-1', 'run-1', 'node-1', false, undefined)
|
|
expect(managerMock.stopRun).not.toHaveBeenCalled()
|
|
expect(c.body).toEqual({ ok: true })
|
|
})
|
|
|
|
it('rejects workflow runs for unavailable profiles', async () => {
|
|
managerMock.get.mockReturnValue({ id: 'workflow-1', profile: 'secret' })
|
|
listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }])
|
|
|
|
const mod = await import('../../packages/server/src/controllers/hermes/workflows')
|
|
const c = ctx({
|
|
params: { id: 'workflow-1' },
|
|
state: { user: { id: 'user-1', role: 'user' } },
|
|
})
|
|
|
|
await mod.runNow(c)
|
|
|
|
expect(c.status).toBe(403)
|
|
expect(managerMock.runNow).not.toHaveBeenCalled()
|
|
})
|
|
})
|