Skip to content
Merged
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
9 changes: 7 additions & 2 deletions packages/agent-client/src/approval-request-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export type ApprovalRequestPayload = {
inputs: ApprovalRequestInput[];
};

export type CreateApprovalRequest = (payload: ApprovalRequestPayload) => Promise<void>;
export type CreateApprovalRequest = (
payload: ApprovalRequestPayload,
) => Promise<{ id: string } | undefined>;

const APPROVAL_REQUEST_PATH = '/api/action-approvals';

Expand All @@ -19,7 +21,8 @@ export default function makeCreateApprovalRequest(options: {
renderingId: number | string;
}): CreateApprovalRequest {
return async payload => {
await ServerUtils.queryWithBearerToken({
// JSON:API create response — the created approval's id is the resource id (data.id).
const body = await ServerUtils.queryWithBearerToken<{ data?: { id?: string | number } }>({
forestServerUrl: options.forestServerUrl,
bearerToken: options.forestServerToken,
method: 'post',
Expand All @@ -39,5 +42,7 @@ export default function makeCreateApprovalRequest(options: {
},
},
});

return body?.data?.id ? { id: String(body.data.id) } : undefined;
};
}
10 changes: 7 additions & 3 deletions packages/agent-client/src/domains/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export type BaseActionContext = {
recordIds?: RecordId[];
};

export type ActionExecuteResult = { success: string; html?: string } | { approvalRequested: true };
export type ActionExecuteResult =
| { success: string; html?: string }
| { approvalRequested: true; approvalRequest?: { id: string } };

export type ActionEndpointsByCollection = {
[collectionName: string]: {
Expand Down Expand Up @@ -151,8 +153,10 @@ export default class Action {
value: field.getValue(),
}));

let approvalRequest: { id: string } | undefined;

try {
await this.createApprovalRequest({
approvalRequest = await this.createApprovalRequest({
collectionName: this.collectionName,
actionName: this.actionName,
recordIds: this.ids ?? [],
Expand All @@ -162,7 +166,7 @@ export default class Action {
throw new ApprovalRequestCreationError(cause);
}

return { approvalRequested: true };
return { approvalRequested: true, ...(approvalRequest && { approvalRequest }) };
}

throw mapped;
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-client/test/approval-request-creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,35 @@ describe('makeCreateApprovalRequest', () => {
},
});
});

it('returns the approval id read from the server response data.id', async () => {
queryWithBearerToken.mockResolvedValue({ data: { id: 'req_42', type: 'action-approvals' } });
const create = makeCreateApprovalRequest({
forestServerUrl: 'https://api.forestadmin.com',
forestServerToken: 'server-token',
renderingId: 42,
});

const result = await create({
collectionName: 'users',
actionName: 'refund',
recordIds: ['1'],
inputs: [],
});

expect(result).toEqual({ id: 'req_42' });
});

it('returns undefined (no throw) when the response carries no usable id', async () => {
queryWithBearerToken.mockResolvedValue({ data: { attributes: { status: 'pending' } } });
const create = makeCreateApprovalRequest({
forestServerUrl: 'https://api.forestadmin.com',
forestServerToken: 'server-token',
renderingId: 42,
});

await expect(
create({ collectionName: 'users', actionName: 'refund', recordIds: ['1'], inputs: [] }),
).resolves.toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions packages/agent-client/test/domains/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

httpRequester = {
query: jest.fn(),
} as any;

Check warning on line 20 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type

fieldsFormStates = {
getFieldValues: jest.fn().mockReturnValue({ email: 'test@example.com' }),
Expand All @@ -28,7 +28,7 @@
getType: () => 'String',
}),
getLayout: jest.fn().mockReturnValue([]),
} as any;

Check warning on line 31 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type

action = new Action(
'users',
Expand Down Expand Up @@ -98,7 +98,7 @@

await action.execute();

const body = httpRequester.query.mock.calls[0][0].body as any;

Check warning on line 101 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
expect(body.data.attributes).not.toHaveProperty('smart_action_id');
});

Expand Down Expand Up @@ -149,7 +149,7 @@
getType: () => 'String',
getValue: () => 'test@example.com',
},
] as any);

Check warning on line 152 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
const createApprovalRequest = jest.fn().mockResolvedValue(undefined);
const approvalAction = new Action(
'users',
Expand Down Expand Up @@ -184,8 +184,32 @@
expect(result).toEqual({ approvalRequested: true });
});

it('includes the approval request id when the creator returns one', async () => {
fieldsFormStates.getFields.mockReturnValue([] as any);

Check warning on line 188 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
const createApprovalRequest = jest.fn().mockResolvedValue({ id: 'req_42' });
const approvalAction = new Action(
'users',
'send-email',
httpRequester,
'/forest/actions/send-email',
fieldsFormStates,
['1'],
undefined,
createApprovalRequest,
);
httpRequester.query.mockRejectedValue(
new AgentHttpError(403, {
errors: [{ name: 'CustomActionRequiresApprovalError', detail: 'Needs approval' }],
}),
);

const result = await approvalAction.execute();

expect(result).toEqual({ approvalRequested: true, approvalRequest: { id: 'req_42' } });
});

it('throws ApprovalRequestCreationError when filing the approval request fails', async () => {
fieldsFormStates.getFields.mockReturnValue([] as any);

Check warning on line 212 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
const createApprovalRequest = jest.fn().mockRejectedValue(new Error('forest server down'));
const approvalAction = new Action(
'users',
Expand Down Expand Up @@ -313,7 +337,7 @@
fieldsFormStates.getField.mockImplementation((fieldName: string) => {
if (fieldName === 'nonexistent') return null;

return { getName: () => fieldName, getType: () => 'String' } as any;

Check warning on line 340 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
});

const skipped = await action.tryToSetFields({
Expand All @@ -338,8 +362,8 @@
describe('getFields', () => {
it('should return action fields', () => {
fieldsFormStates.getFields.mockReturnValue([
{ getName: () => 'email', getType: () => 'String' } as any,

Check warning on line 365 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
{ getName: () => 'count', getType: () => 'Number' } as any,

Check warning on line 366 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type
]);

const fields = action.getFields();
Expand All @@ -353,7 +377,7 @@
fieldsFormStates.getField.mockReturnValue({
getName: () => 'email',
getType: () => 'String',
} as any);

Check warning on line 380 in packages/agent-client/test/domains/action.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent-client)

Unexpected any. Specify a different type

const field = action.getField('email');
expect(field).toBeDefined();
Expand Down
52 changes: 44 additions & 8 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {
ActionCaller,
ActionForm,
ActionFormField,
AgentPort,
ExecuteActionQuery,
ExecuteActionResult,
GetActionFormInfoQuery,
GetActionFormQuery,
GetRecordQuery,
Expand All @@ -19,6 +21,7 @@ import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/ag
import {
ActionFormValidationError as ClientActionFormValidationError,
ActionRequiresApprovalError as ClientActionRequiresApprovalError,
ApprovalRequestCreationError as ClientApprovalRequestCreationError,
HttpRequester,
createRemoteAgentClient,
} from '@forestadmin/agent-client';
Expand All @@ -29,6 +32,7 @@ import {
ActionRequiresApprovalError,
AgentPortError,
AgentProbeError,
ApprovalRequestCreationError,
RecordNotFoundError,
WorkflowExecutorError,
extractErrorMessage,
Expand All @@ -45,6 +49,10 @@ function mapActionExecutionError(action: string, cause: unknown): unknown {
return new ActionFormValidationError(action, cause);
}

if (cause instanceof ClientApprovalRequestCreationError) {
return new ApprovalRequestCreationError(action, cause);
}

return cause;
}

Expand Down Expand Up @@ -74,11 +82,18 @@ export default class AgentClientAgentPort implements AgentPort {
private readonly agentUrl: string;
private readonly authSecret: string;
private readonly schemaCache: SchemaCache;

constructor(params: { agentUrl: string; authSecret: string; schemaCache: SchemaCache }) {
private readonly forestServerUrl?: string;

constructor(params: {
agentUrl: string;
authSecret: string;
schemaCache: SchemaCache;
forestServerUrl?: string;
}) {
this.agentUrl = params.agentUrl;
this.authSecret = params.authSecret;
this.schemaCache = params.schemaCache;
this.forestServerUrl = params.forestServerUrl;
}

async getRecord({ collection, id, fields }: GetRecordQuery, user: StepUser): Promise<RecordData> {
Expand Down Expand Up @@ -216,10 +231,10 @@ export default class AgentClientAgentPort implements AgentPort {

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

Expand All @@ -234,7 +249,18 @@ export default class AgentClientAgentPort implements AgentPort {
}

try {
return await act.execute();
const executeResult = await act.execute();

return typeof executeResult === 'object' &&
executeResult !== null &&
'approvalRequested' in executeResult
? {
approvalRequested: true,
...(executeResult.approvalRequest && {
approvalRequest: executeResult.approvalRequest,
}),
}
: { result: executeResult };
} catch (cause) {
throw mapActionExecutionError(action, cause);
}
Expand All @@ -259,7 +285,8 @@ export default class AgentClientAgentPort implements AgentPort {
): Promise<ActionForm> {
return this.callAgent('getActionForm', async () => {
const client = this.createClient(user);
const act = await client.collection(collection).action(action, { recordIds: [id] });
const recordIds = id?.length ? [id] : [];
const act = await client.collection(collection).action(action, { recordIds });

// 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).
Expand Down Expand Up @@ -311,11 +338,20 @@ export default class AgentClientAgentPort implements AgentPort {
);
}

private createClient(user: StepUser) {
private createClient(user: StepUser, forestServerToken?: string) {
return createRemoteAgentClient({
url: this.agentUrl,
token: this.mintToken(user),
actionEndpoints: this.buildActionEndpoints(user.renderingId),
...(this.forestServerUrl && forestServerToken
? {
forestServer: {
serverUrl: this.forestServerUrl,
serverToken: forestServerToken,
renderingId: user.renderingId,
},
}
: {}),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export default function toUpdateStepRequest(
context.selectedOption = outcome.selectedOption;
}

if (outcome.type === 'record' && outcome.approvalRequest !== undefined) {
Comment thread
Scra3 marked this conversation as resolved.
context.approvalRequest = outcome.approvalRequest;
}

const attributes: ServerStepHistoryUpdate = {
done: outcome.status !== 'awaiting-input',
context,
Expand Down
1 change: 1 addition & 0 deletions packages/workflow-executor/src/build-workflow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function buildCommonDependencies(options: ExecutorOptions) {
agentUrl: options.agentUrl,
authSecret: options.authSecret,
schemaCache,
forestServerUrl,
});

const activityLogsService = new ActivityLogsService(new ForestHttpApi(), {
Expand Down
11 changes: 11 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ export class ActionRequiresApprovalError extends WorkflowExecutorError {
}
}

// Approval-gated action whose approval request couldn't be filed — neither executed nor approved.
export class ApprovalRequestCreationError extends WorkflowExecutorError {
constructor(actionName: string, cause?: unknown) {
super(
`Action "${actionName}" requires an approval, but the approval request could not be created`,
'This action requires an approval, but the approval request could not be created. Please retry.',
);
this.cause = cause;
}
}

export class RunStorePortError extends UnavailableError {
constructor(operation: string, cause: unknown) {
super(
Expand Down
13 changes: 11 additions & 2 deletions packages/workflow-executor/src/executors/agent-with-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ActionForm,
AgentPort,
ExecuteActionQuery,
ExecuteActionResult,
GetActionFormInfoQuery,
GetActionFormQuery,
GetRecordQuery,
Expand All @@ -22,6 +23,7 @@ export interface AgentWithLogDeps {
schemaResolver: SchemaResolver;
user: StepUser;
activityLog: ActivityLog;
forestServerToken?: string;
}

// Wraps AgentPort and runs each data-access call through the ActivityLog so it records an
Expand All @@ -37,11 +39,14 @@ export default class AgentWithLog {

private readonly activityLog: ActivityLog;

private readonly forestServerToken?: string;

constructor(deps: AgentWithLogDeps) {
this.agentPort = deps.agentPort;
this.schemaResolver = deps.schemaResolver;
this.user = deps.user;
this.activityLog = deps.activityLog;
this.forestServerToken = deps.forestServerToken;
}

async getRecord(query: GetRecordQuery): Promise<RecordData> {
Expand Down Expand Up @@ -95,7 +100,7 @@ export default class AgentWithLog {
);
}

async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise<unknown> {
async executeAction(query: ExecuteActionQuery, opts: WriteOptions): Promise<ExecuteActionResult> {
const { collectionId } = await this.resolveSchema(query.collection);

return this.activityLog.track(
Expand All @@ -107,7 +112,11 @@ export default class AgentWithLog {
label: `triggered the action "${query.action}"`,
},
{
operation: () => this.agentPort.executeAction(query, this.user),
operation: () =>
this.agentPort.executeAction(query, {
user: this.user,
forestServerToken: this.forestServerToken,
}),
beforeCall: opts.beforeCall,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ export default class StepExecutorFactory {
activityLogPort: ActivityLogPort,
fetchRemoteTools: (mcpServerId: string) => Promise<FetchRemoteToolsResult>,
incomingPendingData?: unknown,
forestServerToken?: string,
): Promise<IStepExecutor> {
try {
const context = StepExecutorFactory.buildContext(
step,
contextConfig,
activityLogPort,
incomingPendingData,
forestServerToken,
);

switch (step.stepDefinition.type) {
Expand Down Expand Up @@ -134,6 +136,7 @@ export default class StepExecutorFactory {
cfg: StepContextConfig,
activityLogPort: ActivityLogPort,
incomingPendingData?: unknown,
forestServerToken?: string,
): ExecutionContext {
const schemaResolver = new SchemaResolver(
cfg.schemaCache,
Expand Down Expand Up @@ -161,6 +164,7 @@ export default class StepExecutorFactory {
schemaResolver,
user: step.user,
activityLog,
forestServerToken,
}),
activityLog,
runStore: cfg.runStore,
Expand Down
Loading
Loading