Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type {
ActionForm,
ActionFormField,
AgentPort,
ExecuteActionQuery,
GetActionFormInfoQuery,
GetActionFormQuery,
GetRecordQuery,
GetRelatedDataQuery,
GetSingleRelatedDataQuery,
Expand All @@ -17,13 +20,65 @@ import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-clien
import jsonwebtoken from 'jsonwebtoken';

import {
ActionFormValidationError,
ActionRequiresApprovalError,
AgentPortError,
AgentProbeError,
RecordNotFoundError,
WorkflowExecutorError,
extractErrorMessage,
} from '../errors';

// The agent-client throws `new Error(JSON.stringify({ error: { status, text }, body }))` on a non-2xx
// (superagent's toError sets status + text = raw response body). Parse it back to (status, body).
function parseAgentHttpError(error: unknown): { status?: number; text?: string } {
if (!(error instanceof Error)) return {};

try {
const parsed = JSON.parse(error.message) as { error?: { status?: number; text?: string } };

return { status: parsed.error?.status, text: parsed.error?.text };
} catch {
return {};
}
}

// The agent rejects an approval-gated action with HTTP 403 CustomActionRequiresApprovalError,
// carrying roleIdsAllowedToApprove. Pull the roles out of the response body when present.
function extractRoleIdsAllowedToApprove(text: string): number[] | undefined {
try {
const body = JSON.parse(text) as {
errors?: { data?: { roleIdsAllowedToApprove?: number[] } }[];
data?: { roleIdsAllowedToApprove?: number[] };
};

return (
body.errors?.[0]?.data?.roleIdsAllowedToApprove ??
body.data?.roleIdsAllowedToApprove ??
undefined
);
} catch {
return undefined;
}
}

// Map an action `execute()` failure to a typed error so the step executor can route it (PRD-509):
// approval-403 and validation rejections become distinct fallback-worthy errors; everything else
// (plain permission 403, infra 5xx, network) stays raw → wrapped as AgentPortError = step error.
function mapActionExecutionError(action: string, cause: unknown): unknown {
const { status, text } = parseAgentHttpError(cause);

if (status === 403 && text && text.includes('CustomActionRequiresApprovalError')) {
return new ActionRequiresApprovalError(action, extractRoleIdsAllowedToApprove(text));
}

if (status === 400 || status === 422) {
return new ActionFormValidationError(action, cause);
}

return cause;
}

function toCamelCase(name: string): string {
return name.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase());
}
Expand Down Expand Up @@ -192,15 +247,29 @@ export default class AgentClientAgentPort implements AgentPort {
}

async executeAction(
{ collection, action, id }: ExecuteActionQuery,
{ collection, action, id, values }: ExecuteActionQuery,
user: StepUser,
): Promise<unknown> {
return this.callAgent('executeAction', async () => {
const client = this.createClient(user);
const recordIds = id?.length ? [id] : [];
const act = await client.collection(collection).action(action, { recordIds });

return act.execute();
if (values) {
// setFields is strict (mirrors MCP execute-action): an unknown field is a config/drift
// problem, surfaced as a validation error rather than a silent skip.
try {
await act.setFields(values);
} catch (cause) {
throw new ActionFormValidationError(action, cause);
}
}

try {
return await act.execute();
} catch (cause) {
throw mapActionExecutionError(action, cause);
}
});
}

Expand All @@ -216,6 +285,39 @@ export default class AgentClientAgentPort implements AgentPort {
});
}

async getActionForm(
{ collection, action, id, values }: GetActionFormQuery,
user: StepUser,
): Promise<ActionForm> {
return this.callAgent('getActionForm', async () => {
const client = this.createClient(user);
const act = await client.collection(collection).action(action, { recordIds: [id] });

// Soft-apply so dependent fields are revealed by change hooks; unknown fields (dropped by a
// prior hook) come back in skippedFields rather than throwing (mirrors MCP get-action-form).
const skippedFields = values ? await act.tryToSetFields(values) : [];

const fields = act.getFields().map((field): ActionFormField => {
const base = {
name: field.getName(),
type: field.getType(),
value: field.getValue(),
isRequired: field.isRequired() ?? false,
};

return field.getType() === 'Enum'
? { ...base, enumValues: act.getEnumField(field.getName()).getOptions() ?? undefined }
: base;
});

const requiredFields = fields
.filter(field => field.isRequired && (field.value === undefined || field.value === null))
.map(field => field.name);

return { fields, canExecute: requiredFields.length === 0, requiredFields, skippedFields };
});
}

private async callAgent<T>(operation: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
Expand Down
29 changes: 29 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,35 @@ export class UnsupportedActionFormError extends WorkflowExecutorError {
}
}

// The action submission was rejected by the agent's server-side validation (bad/missing values),
// NOT an infra failure (PRD-509). Full AI (PRD-512) treats this as a fallback-to-AI-assisted reason
// so a human can fix the values and resubmit.
export class ActionFormValidationError extends WorkflowExecutorError {
constructor(actionName: string, cause?: unknown) {
super(
`Action "${actionName}" rejected the submitted form values`,
'The submitted form values were rejected. Please review the form and try again.',
);
this.cause = cause;
}
}

// The action requires an Approval: the agent rejects a programmatic submit upfront with HTTP 403
// CustomActionRequiresApprovalError (PRD-509). Distinct from a plain permission 403 — Full AI
// (PRD-512) falls back to AI-assisted so the native front handles the approval flow. The executor
// MUST NOT self-sign an approval request.
export class ActionRequiresApprovalError extends WorkflowExecutorError {
readonly roleIdsAllowedToApprove?: number[];

constructor(actionName: string, roleIdsAllowedToApprove?: number[]) {
super(
`Action "${actionName}" requires an approval and cannot be submitted programmatically`,
'This action requires an approval. Please review and submit it manually.',
);
this.roleIdsAllowedToApprove = roleIdsAllowedToApprove;
}
}

export class RunStorePortError extends UnavailableError {
constructor(operation: string, cause: unknown) {
super(
Expand Down
42 changes: 41 additions & 1 deletion packages/workflow-executor/src/ports/agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,44 @@ export type GetSingleRelatedDataQuery = {
fields?: string[];
};

export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] };
export type ExecuteActionQuery = {
collection: string;
action: string;
id?: Id[];
// Pre-filled form values (PRD-509). Set on the form before execution, going through the agent's
// normal server-side validation — no bypass. Omitted for formless actions.
values?: Record<string, unknown>;
};

export type GetActionFormQuery = {
collection: string;
action: string;
id: Id[];
// Optional values to apply before reading the form back (fires the change hooks so dependent
// fields appear/update). Soft-applied: unknown fields are reported in `skippedFields`.
values?: Record<string, unknown>;
};

// One field of an action form, flattened for the executor/AI (PRD-509). Mirrors the MCP
// get-action-form tool's field shape.
export type ActionFormField = {
name: string;
type: string;
value?: unknown;
isRequired: boolean;
// Allowed values for an Enum field — the AI must pick one of these or leave the field empty.
enumValues?: string[];
};

export type ActionForm = {
fields: ActionFormField[];
// True when every required field has a value (the agent-client form state's completeness check).
canExecute: boolean;
// Required fields still missing a value (empty when canExecute is true).
requiredFields: string[];
// Fields from `values` that no longer exist in the form (dropped by dynamic-form change hooks).
skippedFields: string[];
};

export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] };

Expand All @@ -63,6 +100,9 @@ export interface AgentPort {
// Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields
// passed via ActionEndpointsByCollection (populated from the orchestrator's schema).
getActionFormInfo(query: GetActionFormInfoQuery, user: StepUser): Promise<{ hasForm: boolean }>;
// Full form structure for AI form-filling (PRD-509): field list (types, required, enum options),
// completeness (canExecute / requiredFields), and any values dropped by change hooks.
getActionForm(query: GetActionFormQuery, user: StepUser): Promise<ActionForm>;
// Startup healthcheck. Throws AgentProbeError on network error, timeout, or non-2xx.
// JWT is not verified here — it's validated naturally when the first step runs.
probe(): Promise<void>;
Expand Down
Loading
Loading