feat(ai): add AI tools to list and inspect workflow runs (#21983)
- Add `get_workflow_run` and `list_workflow_runs` AI tools so the workflow agent can troubleshoot failed or misbehaving workflow runs — listing runs with optional filters (workflow, status, limit) and inspecting a specific run's steps, errors, and failed step logs. - Enforce `rolePermissionConfig` on all three read tools (`get_workflow_run`, `list_workflow_runs`, `get_workflow_current_version`) instead of bypassing permission checks, consistent with how `create_complete_workflow` and database CRUD tools work. - Add unit tests for the three tools covering permission forwarding, success paths, and error paths. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21983?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+18
@@ -25,6 +25,7 @@ You help users create and manage automation workflows.
|
||||
- Create workflows from scratch
|
||||
- Modify existing workflows (add, remove, update steps)
|
||||
- Explain workflow structure and suggest improvements
|
||||
- Troubleshoot workflow runs (inspect status, failed steps, and execution logs)
|
||||
|
||||
## Key Concepts
|
||||
|
||||
@@ -67,6 +68,23 @@ LOGIC_FUNCTION steps execute logic functions provided by installed applications.
|
||||
{ "stepType": "LOGIC_FUNCTION", "workflowVersionId": "<version-id>", "defaultSettings": { "input": { "logicFunctionId": "<logic-function-id>" } } }
|
||||
3. Or when using \`create_complete_workflow\`, include a step with type "LOGIC_FUNCTION" and settings.input.logicFunctionId.
|
||||
|
||||
## Troubleshooting Workflow Runs
|
||||
|
||||
When a user reports a failing or misbehaving workflow, diagnose it with two read-only tools:
|
||||
|
||||
- \`list_workflow_runs\`: lists runs (optional \`workflowId\`, optional \`status\`, optional \`limit\`), most recent first. Each result carries \`id\`, \`name\`, \`status\`, run-level \`error\`, \`startedAt\`, \`endedAt\`, \`workflowId\`, and \`workflowVersionId\`.
|
||||
- \`get_workflow_run\`: returns full details for one run (\`workflowRunId\`) — overall status, run-level error, every step's status/error, and the execution logs of the steps that failed.
|
||||
|
||||
### Resolving the run when no id is given
|
||||
|
||||
For requests like "fix my latest failed workflow" where no run or workflow id is provided, call \`list_workflow_runs\` with \`status\` "FAILED" and NO \`workflowId\` — this returns the most recent failed run across all workflows, and each result already carries \`workflowId\`, \`workflowVersionId\`, and a human-readable \`name\`, so you never need an id from the user. If the user names a specific workflow, resolve its \`workflowId\` first and pass it as a filter.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Identify the run via \`list_workflow_runs\` (use \`limit\` 5 when no \`workflowId\` so you can detect multiple failing workflows).
|
||||
2. If results span multiple \`workflowId\`s, disambiguate by name with the user before editing anything.
|
||||
3. Call \`get_workflow_run\` on the chosen run id to read the failed step(s) and their error/logs.
|
||||
4. Map back to the workflow definition via \`get_workflow_current_version(workflowId)\`, then propose or apply a fix.
|
||||
## PICK_RECORD Steps
|
||||
|
||||
PICK_RECORD selects one record from a candidate pool (settings.input.recordIds) and outputs it for later steps to reference — useful for assignment workflows like picking an owner. Set settings.input.strategy to RANDOM, ROUND_ROBIN, or LOAD_BALANCED; LOAD_BALANCED also needs settings.input.loadBalance.{objectNameSingular, fieldName} to pick the candidate with the fewest related records.
|
||||
|
||||
+14
-2
@@ -4,8 +4,8 @@ import { type ToolSet } from 'ai';
|
||||
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
|
||||
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
@@ -24,7 +24,9 @@ import { createDeactivateWorkflowVersionTool } from 'src/modules/workflow/workfl
|
||||
import { createDeleteWorkflowVersionEdgeTool } from 'src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool';
|
||||
import { createDeleteWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool';
|
||||
import { createGetWorkflowCurrentVersionTool } from 'src/modules/workflow/workflow-tools/tools/get-workflow-current-version.tool';
|
||||
import { createGetWorkflowRunTool } from 'src/modules/workflow/workflow-tools/tools/get-workflow-run.tool';
|
||||
import { createListLogicFunctionToolsTool } from 'src/modules/workflow/workflow-tools/tools/list-logic-function-tools.tool';
|
||||
import { createListWorkflowRunsTool } from 'src/modules/workflow/workflow-tools/tools/list-workflow-runs.tool';
|
||||
import { createUpdateAgentTool } from 'src/modules/workflow/workflow-tools/tools/update-agent.tool';
|
||||
import { createUpdateLogicFunctionSourceTool } from 'src/modules/workflow/workflow-tools/tools/update-logic-function-source.tool';
|
||||
import { createUpdateWorkflowVersionPositionsTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool';
|
||||
@@ -122,7 +124,15 @@ export class WorkflowToolWorkspaceService {
|
||||
);
|
||||
const getWorkflowCurrentVersion = createGetWorkflowCurrentVersionTool(
|
||||
this.deps,
|
||||
context,
|
||||
contextWithPermissions,
|
||||
);
|
||||
const getWorkflowRun = createGetWorkflowRunTool(
|
||||
this.deps,
|
||||
contextWithPermissions,
|
||||
);
|
||||
const listWorkflowRuns = createListWorkflowRunsTool(
|
||||
this.deps,
|
||||
contextWithPermissions,
|
||||
);
|
||||
const updateLogicFunctionSource = createUpdateLogicFunctionSourceTool(
|
||||
this.deps,
|
||||
@@ -149,6 +159,8 @@ export class WorkflowToolWorkspaceService {
|
||||
[deactivateWorkflowVersion.name]: deactivateWorkflowVersion,
|
||||
[computeStepOutputSchema.name]: computeStepOutputSchema,
|
||||
[getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion,
|
||||
[getWorkflowRun.name]: getWorkflowRun,
|
||||
[listWorkflowRuns.name]: listWorkflowRuns,
|
||||
[updateLogicFunctionSource.name]: updateLogicFunctionSource,
|
||||
[listLogicFunctionTools.name]: listLogicFunctionTools,
|
||||
[updateAgent.name]: updateAgent,
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
import { createGetWorkflowCurrentVersionTool } from '../get-workflow-current-version.tool';
|
||||
|
||||
const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419';
|
||||
const WORKFLOW_ID = '20202020-bbbb-4d02-bf25-6aeccf7ea419';
|
||||
const ROLE_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const buildContext = () => ({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rolePermissionConfig: { intersectionOf: [ROLE_ID] },
|
||||
});
|
||||
|
||||
const buildDeps = ({
|
||||
workflow,
|
||||
versions,
|
||||
}: {
|
||||
workflow: unknown;
|
||||
versions: unknown[];
|
||||
}) => {
|
||||
const getRepositoryMock = jest.fn();
|
||||
|
||||
getRepositoryMock.mockResolvedValueOnce({
|
||||
findOne: jest.fn().mockResolvedValue(workflow),
|
||||
});
|
||||
|
||||
getRepositoryMock.mockResolvedValueOnce({
|
||||
find: jest.fn().mockResolvedValue(versions),
|
||||
});
|
||||
|
||||
return {
|
||||
globalWorkspaceOrmManager: {
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation(async (fn) => fn()),
|
||||
getRepository: getRepositoryMock,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('get_workflow_current_version tool', () => {
|
||||
it('should pass rolePermissionConfig to both getRepository calls', async () => {
|
||||
const context = buildContext();
|
||||
const deps = buildDeps({
|
||||
workflow: { id: WORKFLOW_ID },
|
||||
versions: [
|
||||
{
|
||||
id: 'v1',
|
||||
status: WorkflowVersionStatus.DRAFT,
|
||||
workflowId: WORKFLOW_ID,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tool = createGetWorkflowCurrentVersionTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
await tool.execute({ workflowId: WORKFLOW_ID });
|
||||
|
||||
expect(
|
||||
deps.globalWorkspaceOrmManager.getRepository,
|
||||
).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
WORKSPACE_ID,
|
||||
'workflow',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
expect(
|
||||
deps.globalWorkspaceOrmManager.getRepository,
|
||||
).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
WORKSPACE_ID,
|
||||
'workflowVersion',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return draft version over active version', async () => {
|
||||
const context = buildContext();
|
||||
const deps = buildDeps({
|
||||
workflow: { id: WORKFLOW_ID },
|
||||
versions: [
|
||||
{
|
||||
id: 'v-active',
|
||||
name: 'Active',
|
||||
status: WorkflowVersionStatus.ACTIVE,
|
||||
workflowId: WORKFLOW_ID,
|
||||
trigger: null,
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
id: 'v-draft',
|
||||
name: 'Draft',
|
||||
status: WorkflowVersionStatus.DRAFT,
|
||||
workflowId: WORKFLOW_ID,
|
||||
trigger: null,
|
||||
steps: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tool = createGetWorkflowCurrentVersionTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({ workflowId: WORKFLOW_ID });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
if (
|
||||
!('workflowVersion' in result) ||
|
||||
result.workflowVersion === undefined
|
||||
) {
|
||||
throw new Error('Expected workflowVersion to be present in the result');
|
||||
}
|
||||
|
||||
expect(result.workflowVersion.id).toBe('v-draft');
|
||||
});
|
||||
|
||||
it('should return error when workflow is not found', async () => {
|
||||
const context = buildContext();
|
||||
const deps = buildDeps({ workflow: null, versions: [] });
|
||||
|
||||
const tool = createGetWorkflowCurrentVersionTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({ workflowId: WORKFLOW_ID });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WORKFLOW_ID);
|
||||
});
|
||||
|
||||
it('should return error when no draft or active version exists', async () => {
|
||||
const context = buildContext();
|
||||
const deps = buildDeps({ workflow: { id: WORKFLOW_ID }, versions: [] });
|
||||
|
||||
const tool = createGetWorkflowCurrentVersionTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({ workflowId: WORKFLOW_ID });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('no draft or active version');
|
||||
});
|
||||
});
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
|
||||
import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
import { createGetWorkflowRunTool } from '../get-workflow-run.tool';
|
||||
|
||||
const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419';
|
||||
const WORKFLOW_RUN_ID = '20202020-bbbb-4d02-bf25-6aeccf7ea419';
|
||||
const ROLE_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const buildDeps = (findOneResult: unknown) => ({
|
||||
globalWorkspaceOrmManager: {
|
||||
executeInWorkspaceContext: jest.fn().mockImplementation(async (fn) => fn()),
|
||||
getRepository: jest.fn().mockResolvedValue({
|
||||
findOne: jest.fn().mockResolvedValue(findOneResult),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const buildContext = () => ({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rolePermissionConfig: { intersectionOf: [ROLE_ID] },
|
||||
});
|
||||
|
||||
describe('get_workflow_run tool', () => {
|
||||
it('should pass rolePermissionConfig to getRepository', async () => {
|
||||
const deps = buildDeps({ id: WORKFLOW_RUN_ID, state: {}, stepLogs: {} });
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createGetWorkflowRunTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
await tool.execute({ workflowRunId: WORKFLOW_RUN_ID });
|
||||
|
||||
expect(deps.globalWorkspaceOrmManager.getRepository).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'workflowRun',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return workflow run details on success', async () => {
|
||||
const workflowRun = {
|
||||
id: WORKFLOW_RUN_ID,
|
||||
name: 'Test Run',
|
||||
status: 'FAILED',
|
||||
startedAt: '2025-01-01T00:00:00Z',
|
||||
endedAt: '2025-01-01T00:01:00Z',
|
||||
enqueuedAt: null,
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'wfv-1',
|
||||
state: {
|
||||
workflowRunError: 'Something went wrong',
|
||||
flow: {
|
||||
steps: [
|
||||
{ id: 'step-1', name: 'Step 1', type: 'CODE' },
|
||||
{ id: 'step-2', name: 'Step 2', type: 'SEND_EMAIL' },
|
||||
],
|
||||
},
|
||||
stepInfos: {
|
||||
'step-1': { status: StepStatus.SUCCESS },
|
||||
'step-2': { status: StepStatus.FAILED, error: 'Email failed' },
|
||||
},
|
||||
},
|
||||
stepLogs: {
|
||||
'step-2': [{ message: 'SMTP error' }],
|
||||
},
|
||||
};
|
||||
|
||||
const deps = buildDeps(workflowRun);
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createGetWorkflowRunTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({ workflowRunId: WORKFLOW_RUN_ID });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
if (!('workflowRun' in result) || result.workflowRun === undefined) {
|
||||
throw new Error('Expected workflowRun to be present in the result');
|
||||
}
|
||||
|
||||
expect(result.workflowRun.id).toBe(WORKFLOW_RUN_ID);
|
||||
expect(result.workflowRun.error).toBe('Something went wrong');
|
||||
expect(result.workflowRun.steps).toHaveLength(2);
|
||||
expect(result.workflowRun.steps[1].error).toBe('Email failed');
|
||||
expect(result.workflowRun.failedStepLogs).toEqual({
|
||||
'step-2': [{ message: 'SMTP error' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when workflow run is not found', async () => {
|
||||
const deps = buildDeps(null);
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createGetWorkflowRunTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({ workflowRunId: WORKFLOW_RUN_ID });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WORKFLOW_RUN_ID);
|
||||
});
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
import { createListWorkflowRunsTool } from '../list-workflow-runs.tool';
|
||||
|
||||
const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419';
|
||||
const ROLE_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const buildDeps = (findResult: unknown[]) => ({
|
||||
globalWorkspaceOrmManager: {
|
||||
executeInWorkspaceContext: jest.fn().mockImplementation(async (fn) => fn()),
|
||||
getRepository: jest.fn().mockResolvedValue({
|
||||
find: jest.fn().mockResolvedValue(findResult),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const buildContext = () => ({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rolePermissionConfig: { intersectionOf: [ROLE_ID] },
|
||||
});
|
||||
|
||||
describe('list_workflow_runs tool', () => {
|
||||
it('should pass rolePermissionConfig to getRepository', async () => {
|
||||
const deps = buildDeps([]);
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createListWorkflowRunsTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
expect(deps.globalWorkspaceOrmManager.getRepository).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'workflowRun',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return workflow runs on success', async () => {
|
||||
const workflowRuns = [
|
||||
{
|
||||
id: 'run-1',
|
||||
name: 'Run 1',
|
||||
status: WorkflowRunStatus.COMPLETED,
|
||||
state: {},
|
||||
startedAt: '2025-01-01T00:00:00Z',
|
||||
endedAt: '2025-01-01T00:01:00Z',
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'wfv-1',
|
||||
},
|
||||
{
|
||||
id: 'run-2',
|
||||
name: 'Run 2',
|
||||
status: WorkflowRunStatus.FAILED,
|
||||
state: { workflowRunError: 'Timeout' },
|
||||
startedAt: '2025-01-02T00:00:00Z',
|
||||
endedAt: '2025-01-02T00:01:00Z',
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'wfv-1',
|
||||
},
|
||||
];
|
||||
|
||||
const deps = buildDeps(workflowRuns);
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createListWorkflowRunsTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
if (!('workflowRuns' in result)) {
|
||||
throw new Error('Expected workflowRuns to be present in the result');
|
||||
}
|
||||
|
||||
expect(result.workflowRuns).toHaveLength(2);
|
||||
expect(result.workflowRuns[0].id).toBe('run-1');
|
||||
expect(result.workflowRuns[1].error).toBe('Timeout');
|
||||
});
|
||||
|
||||
it('should apply filters when provided', async () => {
|
||||
const findMock = jest.fn().mockResolvedValue([]);
|
||||
const deps = {
|
||||
globalWorkspaceOrmManager: {
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation(async (fn) => fn()),
|
||||
getRepository: jest.fn().mockResolvedValue({ find: findMock }),
|
||||
},
|
||||
};
|
||||
const context = buildContext();
|
||||
|
||||
const tool = createListWorkflowRunsTool(
|
||||
deps as unknown as Pick<
|
||||
WorkflowToolDependencies,
|
||||
'globalWorkspaceOrmManager'
|
||||
>,
|
||||
context,
|
||||
);
|
||||
|
||||
await tool.execute({
|
||||
workflowId: 'wf-1',
|
||||
status: WorkflowRunStatus.FAILED,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith({
|
||||
where: { workflowId: 'wf-1', status: WorkflowRunStatus.FAILED },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-3
@@ -1,6 +1,7 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
WorkflowVersionStatus,
|
||||
@@ -12,6 +13,10 @@ import {
|
||||
type WorkflowToolDependencies,
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
type GetWorkflowCurrentVersionToolContext = WorkflowToolContext & {
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
};
|
||||
|
||||
const getWorkflowCurrentVersionSchema = z.object({
|
||||
workflowId: z
|
||||
.string()
|
||||
@@ -25,7 +30,7 @@ type GetWorkflowCurrentVersionInput = z.infer<
|
||||
|
||||
export const createGetWorkflowCurrentVersionTool = (
|
||||
deps: Pick<WorkflowToolDependencies, 'globalWorkspaceOrmManager'>,
|
||||
context: WorkflowToolContext,
|
||||
context: GetWorkflowCurrentVersionToolContext,
|
||||
) => ({
|
||||
name: 'get_workflow_current_version' as const,
|
||||
description:
|
||||
@@ -41,7 +46,7 @@ export const createGetWorkflowCurrentVersionTool = (
|
||||
await deps.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
|
||||
context.workspaceId,
|
||||
'workflow',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
|
||||
const workflow = await workflowRepository.findOne({
|
||||
@@ -59,7 +64,7 @@ export const createGetWorkflowCurrentVersionTool = (
|
||||
await deps.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
|
||||
context.workspaceId,
|
||||
'workflowVersion',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
|
||||
const versions = await workflowVersionRepository.find({
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { type WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
type WorkflowToolDependencies,
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
type GetWorkflowRunToolContext = WorkflowToolContext & {
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
};
|
||||
|
||||
const getWorkflowRunSchema = z.object({
|
||||
workflowRunId: z.uuid().describe('The UUID of the workflow run to inspect'),
|
||||
});
|
||||
|
||||
type GetWorkflowRunInput = z.infer<typeof getWorkflowRunSchema>;
|
||||
|
||||
const FAILED_STEP_STATUSES: StepStatus[] = [
|
||||
StepStatus.FAILED,
|
||||
StepStatus.FAILED_SAFELY,
|
||||
];
|
||||
|
||||
export const createGetWorkflowRunTool = (
|
||||
deps: Pick<WorkflowToolDependencies, 'globalWorkspaceOrmManager'>,
|
||||
context: GetWorkflowRunToolContext,
|
||||
) => ({
|
||||
name: 'get_workflow_run' as const,
|
||||
description:
|
||||
'Get the details of a single workflow run for troubleshooting. Returns the overall status, the run-level error, the status and error of each step, and the execution logs of the steps that failed. Use this to diagnose why a workflow run failed or behaved unexpectedly.',
|
||||
inputSchema: getWorkflowRunSchema,
|
||||
execute: async (parameters: GetWorkflowRunInput) => {
|
||||
try {
|
||||
const authContext = buildSystemAuthContext(context.workspaceId);
|
||||
|
||||
return await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const workflowRunRepository =
|
||||
await deps.globalWorkspaceOrmManager.getRepository<WorkflowRunWorkspaceEntity>(
|
||||
context.workspaceId,
|
||||
'workflowRun',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
|
||||
const workflowRun = await workflowRunRepository.findOne({
|
||||
where: { id: parameters.workflowRunId },
|
||||
});
|
||||
|
||||
if (!isDefined(workflowRun)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow run ${parameters.workflowRunId} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const stepInfos = workflowRun.state?.stepInfos ?? {};
|
||||
|
||||
const steps = (workflowRun.state?.flow?.steps ?? []).map((step) => {
|
||||
const stepInfo = stepInfos[step.id];
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
type: step.type,
|
||||
status: stepInfo?.status,
|
||||
error: stepInfo?.error,
|
||||
};
|
||||
});
|
||||
|
||||
const failedStepIds = Object.entries(stepInfos)
|
||||
.filter(([, stepInfo]) =>
|
||||
FAILED_STEP_STATUSES.includes(stepInfo.status),
|
||||
)
|
||||
.map(([stepId]) => stepId);
|
||||
|
||||
const failedStepLogs = Object.fromEntries(
|
||||
Object.entries(workflowRun.stepLogs ?? {}).filter(([stepId]) =>
|
||||
failedStepIds.includes(stepId),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflowRun: {
|
||||
id: workflowRun.id,
|
||||
name: workflowRun.name,
|
||||
status: workflowRun.status,
|
||||
error: workflowRun.state?.workflowRunError,
|
||||
startedAt: workflowRun.startedAt,
|
||||
endedAt: workflowRun.endedAt,
|
||||
enqueuedAt: workflowRun.enqueuedAt,
|
||||
workflowId: workflowRun.workflowId,
|
||||
workflowVersionId: workflowRun.workflowVersionId,
|
||||
steps,
|
||||
failedStepLogs,
|
||||
},
|
||||
};
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: `Failed to get workflow run: ${error.message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type FindOptionsWhere } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
WorkflowRunStatus,
|
||||
type WorkflowRunWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
type WorkflowToolDependencies,
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
type ListWorkflowRunsToolContext = WorkflowToolContext & {
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const listWorkflowRunsSchema = z.object({
|
||||
workflowId: z
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter runs by the UUID of the workflow they belong to'),
|
||||
status: z
|
||||
.nativeEnum(WorkflowRunStatus)
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter runs by status (e.g. FAILED to find runs that need troubleshooting)',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_LIMIT)
|
||||
.optional()
|
||||
.describe(`Maximum number of runs to return (default ${DEFAULT_LIMIT})`),
|
||||
});
|
||||
|
||||
type ListWorkflowRunsInput = z.infer<typeof listWorkflowRunsSchema>;
|
||||
|
||||
export const createListWorkflowRunsTool = (
|
||||
deps: Pick<WorkflowToolDependencies, 'globalWorkspaceOrmManager'>,
|
||||
context: ListWorkflowRunsToolContext,
|
||||
) => ({
|
||||
name: 'list_workflow_runs' as const,
|
||||
description:
|
||||
'List workflow runs, optionally filtered by workflow and/or status, ordered from most to least recent. Use this to find the relevant run (for example the latest failed run of a workflow) before inspecting it in detail with get_workflow_run.',
|
||||
inputSchema: listWorkflowRunsSchema,
|
||||
execute: async (parameters: ListWorkflowRunsInput) => {
|
||||
try {
|
||||
const authContext = buildSystemAuthContext(context.workspaceId);
|
||||
|
||||
return await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const workflowRunRepository =
|
||||
await deps.globalWorkspaceOrmManager.getRepository<WorkflowRunWorkspaceEntity>(
|
||||
context.workspaceId,
|
||||
'workflowRun',
|
||||
context.rolePermissionConfig,
|
||||
);
|
||||
|
||||
const where: FindOptionsWhere<WorkflowRunWorkspaceEntity> = {};
|
||||
|
||||
if (isDefined(parameters.workflowId)) {
|
||||
where.workflowId = parameters.workflowId;
|
||||
}
|
||||
|
||||
if (isDefined(parameters.status)) {
|
||||
where.status = parameters.status;
|
||||
}
|
||||
|
||||
const workflowRuns = await workflowRunRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: parameters.limit ?? DEFAULT_LIMIT,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflowRuns: workflowRuns.map((workflowRun) => ({
|
||||
id: workflowRun.id,
|
||||
name: workflowRun.name,
|
||||
status: workflowRun.status,
|
||||
error: workflowRun.state?.workflowRunError,
|
||||
startedAt: workflowRun.startedAt,
|
||||
endedAt: workflowRun.endedAt,
|
||||
workflowId: workflowRun.workflowId,
|
||||
workflowVersionId: workflowRun.workflowVersionId,
|
||||
})),
|
||||
};
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: `Failed to list workflow runs: ${error.message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user