Skip to content

feat(workflow-executor): approval requests on Full AI / Automatic trigger actions [PRD-688]#1720

Merged
Scra3 merged 10 commits into
mainfrom
feature/prd-688-approval-on-full-ai-trigger-action
Jul 1, 2026
Merged

feat(workflow-executor): approval requests on Full AI / Automatic trigger actions [PRD-688]#1720
Scra3 merged 10 commits into
mainfrom
feature/prd-688-approval-on-full-ai-trigger-action

Conversation

@Scra3

@Scra3 Scra3 commented Jun 29, 2026

Copy link
Copy Markdown
Member

Context

PRD-688 (backend / executor half, single PR per repo). In the legacy browser engine, triggering an approval-gated action created the approval on the frontend. With the backend migration, Automatic & Full AI Trigger Action steps run on the executor, which didn't create the approval — a regression. This PR makes the executor file the approval request (non-blocking) and handles action scope correctly.

What it does

  1. Create the approval request when the triggering user's role requires it — instead of executing — and report the step as success (the run continues; the process owner adds an explicit stop if needed). The created approval id is forwarded to the front via the StepOutcome (context.approvalRequest.id) so it can deep-link.
  2. Global actions carry no record. A global action runs on no record, so the executor no longer attaches its source record (the approval would otherwise show a nonsensical Resource). Single/bulk actions keep their record. Relies on the action type forwarded by the orchestrator (ForestAdmin/forestadmin-server#8344); degrades safely if absent.
  3. Approval-creation failure → step error with a dedicated ApprovalRequestCreationError.

Key changes

  • agent-client: makeCreateApprovalRequest returns the created approval id (defensive extraction); Action.execute() bubbles { approvalRequested, approvalRequest }.
  • workflow-executor: wire forestServer (per-step forestServerToken threaded runner → factory → AgentWithLog → port); typed ExecuteActionResult; the trigger-action executor branches on approval (persists pending-approval, no actionResult), omits the record for global actions, reconstructs the approval id on idempotent replay; outcome + update-step mapper carry approvalRequest.

Architecture note

approvalRequest is contained to the trigger-action executor: a dedicated buildOutcomeResult override (mirroring condition's selectedOption), not the shared base/record builders. The action-scope decision stays in the executor and reduces to "id present or not" at the port boundary — lower layers stay generic.

Verification

agent-client (340) · mcp-server (547) · workflow-executor (1131) tests pass; build + lint clean.

Frontend half: ForestAdmin/forestadmin#9804. Orchestrator (forwards action type): ForestAdmin/forestadmin-server#8344.

fixes PRD-688

🤖 Generated with Claude Code

Note

Add approval request handling for Full AI and Automatic trigger actions

  • When an action requires approval, TriggerRecordActionStepExecutor now persists a pending-approval result and returns a success outcome instead of blocking, optionally including the approval request id for deep-linking.
  • AgentClientAgentPort.executeAction returns a normalized ExecuteActionResult union distinguishing executed results from approval-gated submissions, and wires Forest server connectivity when a forestServerToken is provided.
  • Global actions (type 'global') are now supported: record id is omitted when building forms and executing actions.
  • Idempotent replay of completed pending-approval steps restores the saved approvalRequest id without re-triggering execution.
  • The forestServerToken is propagated from each dispatch through RunnerStepExecutorFactoryAgentWithLogAgentClientAgentPort to enable approval request creation.

Macroscope summarized baa7964.

@linear-code

linear-code Bot commented Jun 29, 2026

Copy link
Copy Markdown

PRD-688

@qltysh

qltysh Bot commented Jun 29, 2026

Copy link
Copy Markdown

10 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): mapActionExecutionError 4
qlty Structure Function with many parameters (count = 6): create 3
qlty Structure High total complexity (count = 55) 2
qlty Structure Function with high complexity (count = 12): executeAction 1

@qltysh

qltysh Bot commented Jun 29, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.08%.

Modified Files with Diff Coverage (7)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-client/src/approval-request-creator.ts100.0%
Coverage rating: A Coverage rating: A
...-executor/src/executors/trigger-record-action-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/domains/action.ts100.0%
Coverage rating: A Coverage rating: A
...ages/workflow-executor/src/adapters/agent-client-agent-port.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/adapters/step-outcome-to-update-step-mapper.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/agent-with-log.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

…ction when required by role

PRD-688 (backend / executor half). When a Trigger Action step runs in
Automatic / Full AI mode and the action requires an approval by the triggering
user's role, the executor now files an approval request instead of executing,
reports the step as success (non-blocking — the run continues), and forwards
the created approval id to the front via the StepOutcome so the UI can deep-link
to the approval request.

- agent-client: makeCreateApprovalRequest returns the created approval id
  (defensive extraction from the JSON:API response); Action.execute() bubbles it
  as { approvalRequested, approvalRequest }.
- workflow-executor: wire forestServer (per-step forestServerToken threaded
  runner -> factory -> AgentWithLog -> port) so the agent-client can file the
  request; type the executeAction result; branch in the trigger-action executor
  (persist pending-approval, no actionResult); carry approvalRequest through the
  outcome + update-step mapper; reconstruct it on idempotent replay; add a
  dedicated ApprovalRequestCreationError.

fixes PRD-688

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3 Scra3 force-pushed the feature/prd-688-approval-on-full-ai-trigger-action branch from 7de1dfc to ee7a332 Compare June 29, 2026 16:27
…ction executor

approvalRequest is a Trigger Action concern, but it had leaked into shared
outcome builders: the base buildOutcomeResult abstract (seen by every executor)
and record-step-executor's concrete (shared by read/update/load-related).

Pull it back to where it's used, mirroring the existing selectedOption pattern:
- base abstract stays { status, error? } — no family-specific field.
- record-step-executor's builder stays { status, error? }.
- trigger-record-action-step-executor declares a dedicated buildOutcomeResult
  override carrying approvalRequest, delegating to super (which spreads it onto
  the record StepOutcome).

No behavior change; the approval id still reaches the outcome. The shared
RecordStepOutcome wire type keeps the optional field (single 'record' outcome
type for all record steps — unchanged).

Refs PRD-688

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3 Scra3 force-pushed the feature/prd-688-approval-on-full-ai-trigger-action branch from 563eb37 to afb1453 Compare June 29, 2026 20:16
…proval

A global action runs on no record, so the executor must not pass the Trigger
Action step's source record when triggering it — otherwise the approval request
it files is wrongly linked to that record (shows a Resource that makes no sense).
Read the action `type` (forwarded by the orchestrator) and omit the record id
for global actions; single/bulk actions keep their record.

Refs PRD-688

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3 Scra3 changed the title feat(workflow-executor): create approval request on Full AI trigger-action when required by role [PRD-688] feat(workflow-executor): approval requests on Full AI / Automatic trigger actions [PRD-688] Jun 29, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alban bertolini and others added 4 commits June 30, 2026 10:04
…lative fallbacks)

The Forest server returns the created approval as JSON:API (serializer ref:'id'),
so the id is data.id — there is no reference/requestId attribute. Drop the
defensive multi-field fallback now that the response shape is known.

Refs PRD-688

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the single-use extractApprovalId helper + its type; read data.id at the call site.

Refs PRD-688

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the loose `forestServerToken?: string` third arg on
AgentPort.executeAction with a `caller: { user, forestServerToken }`
object. Keeps the token off StepUser (it would leak into the agent JWT
via mintToken's user splat) while keeping it on the only method that
talks to the Forest server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lobal actions

getActionForm was passing the source record id even for global actions,
so record-scoped defaults/dynamic-fields/validation were computed for the
wrong scope while executeAction (correctly) omits the id. Omit the id in
both the form-detection call and the AI-fill re-fetch, matching
executeAction. GetActionFormQuery.id is now optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3

Scra3 commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@matthv

matthv commented Jul 1, 2026

Copy link
Copy Markdown
Member

Review notes — no correctness blockers, a few things worth hardening

🔴 Should fix before merge

Silent loss of the approval deep-link when the server returns no data.idpackages/agent-client/src/approval-request-creator.ts:46
The POST succeeded (the approval exists server-side), but if the response has no usable data.id, we silently return undefined. The run then reports success, persists pending-approval with no id, forwards no deep-link — and logs nothing. Later a user sees "pending-approval but no link to approve" and logs explain nothing.
The non-throwing fallback is the right call; the silence isn't. Please add a warn log (with collectionName, actionName, renderingId) when the POST succeeds yet data.id is absent. Same for the idempotent-replay coalesce at trigger-record-action-step-executor.ts:65-68.

🟡 Worth addressing

Global-vs-record misclassification is silent if type is absenttrigger-record-action-step-executor.ts:148
isGlobal: action.type === 'global', and type is .optional(). If the orchestrator (#8344) omits it, a global action is silently treated as record-scoped → a recordId gets wrongly attached to the approval. Fine only if the orchestrator contract guarantees type is always sent — worth confirming, since that guarantee lives cross-repo and isn't enforced here.

Test gap — checkIdempotency replay only covers pending-approvaltrigger-record-action-step-executor.ts:63-74
The !('skipped' in result) guard and the executed-result replay (the common Full-AI happy path) have no test. Two cheap cases: phase=done + executed → success/no approvalRequest; phase=done + { skipped: true } → success.

Test gap — adapter-level global-action id omissionagent-client-agent-port.ts:249
getActionForm's new id?.length ? [id] : [] is only tested with id: [1], and those tests never assert the recordIds passed to mockCollection.action. Add a no-id case asserting { recordIds: [] } — that's the parity with executeAction the PR relies on.

…d + cover replay/global-form

Addresses review on #1720:
- Log a Warn (collection/action/rendering) when the approval POST succeeds
  but no id comes back — on both the live path and the idempotent replay —
  so a missing deep-link is diagnosable instead of silent.
- Add replay tests for the executed and skipped outcomes.
- Add an adapter test asserting getActionForm omits recordIds for a global
  action (parity with executeAction).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3

Scra3 commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Thanks @matthv — addressed in 155d199.

🔴 Silent loss of the deep-link when no data.id — agreed, the fallback stays non-throwing but is no longer silent. Added a Warn (with collectionName, actionName, renderingId) on both paths: when the approval POST succeeds with no id (executeOnExecutor), and on the idempotent-replay coalesce (checkIdempotency). Kept the log in the executor rather than agent-client, which has no logger injected — the executor is where the context (collection/action/rendering + context.logger) lives.

🟡 Global-vs-record misclassification if type absent — confirmed the orchestrator contract guarantees it: workflow-orchestrator-service.ts:890 sends type: resolveActionType(apiMapAction) (never undefined — falls back to Bulk), and CollectionSchemaAction.type is a required ActionType (types.ts:426) in #8344. The .optional() here is only defensive for pre-#8344 orchestrators; with the current contract a global action can't be silently record-scoped. Left as-is.

🟡 Replay test gap — added phase=done + executed (→ success, no approvalRequest) and phase=done + { skipped: true } (→ success), both asserting no re-trigger.

🟡 Adapter global-form test gap — added a no-id getActionForm case asserting mockCollection.action is called with { recordIds: [] } (the parity with executeAction the PR relies on).

Adds the replay case (phase=done + pending-approval without an approval id)
that exercises the new Warn branch in checkIdempotency, restoring diff
coverage on the modified file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Scra3 Scra3 merged commit aa3ef58 into main Jul 1, 2026
37 checks passed
@Scra3 Scra3 deleted the feature/prd-688-approval-on-full-ai-trigger-action branch July 1, 2026 13:23
forest-bot added a commit that referenced this pull request Jul 1, 2026
# @forestadmin/agent-client [1.9.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.8.0...@forestadmin/agent-client@1.9.0) (2026-07-01)

### Features

* **workflow-executor:** approval requests on Full AI / Automatic trigger actions [PRD-688] ([#1720](#1720)) ([aa3ef58](aa3ef58))
forest-bot added a commit that referenced this pull request Jul 1, 2026
# @forestadmin/workflow-executor [1.11.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/workflow-executor@1.10.0...@forestadmin/workflow-executor@1.11.0) (2026-07-01)

### Features

* **workflow-executor:** approval requests on Full AI / Automatic trigger actions [PRD-688] ([#1720](#1720)) ([aa3ef58](aa3ef58))

### Dependencies

* **@forestadmin/agent-client:** upgraded to 1.9.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants