feat: Introduce Engine Invoke Post Hooks - #692
Conversation
|
/describe |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
PR Description updated to latest commit (073aed3) |
This comment has been minimized.
This comment has been minimized.
073aed3 to
2fd5acd
Compare
This comment has been minimized.
This comment has been minimized.
2fd5acd to
a9b9158
Compare
This comment has been minimized.
This comment has been minimized.
a9b9158 to
3e6fbe7
Compare
This comment has been minimized.
This comment has been minimized.
3e6fbe7 to
258292b
Compare
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Stale comment
Automated verification (
/verification) — re-run on new commitApproval policy: this automation auto-approves only when a PR is a pure dependency bump (
go.mod/go.sumonly, no other files touched). This PR modifiespkg/app/options.go,pkg/app/app_test.go,pkg/mocks/workflow.go, andpkg/workflow/*.go— core engine code, no dependency-manifest changes — so it does not qualify and is not auto-approved.Note on this automation's prior reviews: this automation previously left two
CHANGES_REQUESTEDreviews on this PR. Per updated policy, this automation will no longer request changes — findings are reported as comments only, and the PR is never blocked by this automation. I attempted to formally dismiss those prior reviews, but the available tooling can only dismiss prior approvals, notCHANGES_REQUESTEDreviews — that requires a maintainer to click "Dismiss review" on GitHub. The concerns those reviews raised have been re-triaged below against the current commit; several are now confirmed fixed.Ran all four verification passes (semantic analysis, adversarial review, security scan, independent code review) against the full diff (base
0980355→ head258292b). CI is green on this exact SHA (build/lint/unit-with-race/Windows/security-scans/Snyk code+license+secrets all pass) and is treated as the build/test oracle.Since the last review (base
0980355→ previous head3e6fbe7)
- ✅ Fixed: hook recursion guard — hooks now receive a nested-tagged
engineWrapper, not the raw*EngineImpl, and the hook loop is wrapped inrecover(). Verified againstTest_EngineWrapper_HookRecursionGuard/Test_PostInvokeHook_PanicRecovery.- ✅ Fixed:
GetWorkflowID()renamed toGetWorkflowIdentifier(), now consistent withInvocationContext.- ✅ Fixed:
WithPostInvokeHooksnow has a dedicated test (Test_WithPostInvokeHooksinapp_test.go).- ❌ Still open: unmarked-as-breaking addition to the exported
Engineinterface — non-blocking, matches existing precedent (GetRuntimeInfo/SetRuntimeInfo) and GAF stays on v0.- ❌ Still open: hooks are silently skipped when
Invokeis called beforeInit()— low real-world impact, informational only.Should Fix (new this round)
Unsynchronized read of
e.loggerin the new panic-recovery handler (pkg/workflow/engineimpl.go:398) — independently found by three of four reviewer passes. Every other access tologgerin this file goes throughe.mu(GetLogger/SetLogger), but the newrecover()handler readse.loggerdirectly with no lock held. A concurrentSetLoggercall while a hook panics is a genuine data race (go test -racewould flag it if a test exercised the combination — none currently do). Fix: usee.GetLogger()instead of the raw field.Recursion guard is a convention on the
engparameter, not a structural invariant ofInvoke(pkg/workflow/engineimpl.go:393, doc comment atpkg/workflow/types.go) — flagged independently by two adversarial-review lenses (Skeptic and Architect). The nested-guard only applies when hook code callsInvokeon theengparameter it's handed. Any hook closure that instead callsInvokeon the raw engine reference it already holds (the same instance used to register the hook viaAddPostInvokeHook/WithPostInvokeHooks) bypasseswithNested()entirely and re-fires all hooks with no cycle guard — recursing until the goroutine's stack overflows. Critically, a stack-overflow crash is not caught by therecover()added for ordinary panics. This repo already has the exact anti-pattern this depends on (pkg/devtools/tree.goinvokes the raw engine directly rather than throughinvocation.GetEngine()), so it's a realistic misuse path, not a hypothetical. Fix direction: either make the nested-tracking structural (e.g. a context value checked at the top ofEngineImpl.Invokeregardless of whichEnginereference is used), or strengthen the doc comment with an explicit warning against invoking through any reference other than theengparameter passed into the hook.
hookEnginedoesn't inherit context/instrumentation defaults, unlike the wrapper built for workflow callbacks (pkg/workflow/engineimpl.go:393) — flagged both by the adversarial review's Architect lens and independently by Bugbot's own review on this commit ("Hook engine lacks invoke defaults"). Contrast withlocalEngine(built a few lines earlier forinvocation.GetEngine()), which setsdefaultInstrumentationCollectoranddefaultCtxFuncso nested calls automatically inherit the parent's context/collector.hookEngine := &engineWrapper{WrappedEngine: e}sets neither, so a hook that callseng.Invoke(id)without explicitly passingWithContext(ctx)silently loses cancellation/trace propagation that an equivalent workflow-side nested call would get for free. Fix:hookEngine := &engineWrapper{WrappedEngine: e, defaultCtxFunc: func() context.Context { return hookCtx }}.Suggestions (non-blocking)
PostInvokeHook's doc comment ("called ... after the workflow callback returns") is inaccurate for the missing-workflow case —Test_PostInvokeHook_FiresForMissingWorkflowconfirms hooks also fire when no callback ever ran. Worth an explicit note to avoid consumers misreadingGetError()as always callback-sourced.- No test covers a nested invoke of a nonexistent workflow (the
optsloop added to the!okbranch specifically exists to keep that case correctly tagged nested — currently unverified by any test).- No test covers a hook observing values/deadlines propagated via
WithContext.- No test exercises a real race between
AddPostInvokeHookand concurrentInvokecalls (current concurrency test registers the hook before any goroutine starts).- Minor:
addPostInvokeHooktest helper and two tests inenginewrapper_test.godo an unnecessarye.(*EngineImpl)type assertion just to callAddPostInvokeHook, which is now part of theEngineinterface — the assertion can be dropped.Security
No exploitable findings. No dependency-manifest changes to scan. Manual trace of the hook mechanism found no attacker-reachable surface (hooks are registered only by trusted host code, not workflow extensions); CI's Snyk code/license/secrets checks are all green.
What's solid
The
AddPostInvokeHook/Invokelocking discipline is correctly serialized (write underLock(), read/copy underRLock(), hooks run outside the lock), nested-invocation tagging propagates correctly through the well-behaved call path, and per-hook panic recovery correctly lets subsequent hooks continue. Test coverage of the intended behavior (ordering, error propagation, missing-workflow, concurrent top-level invocations, nested-skip, nil-hook, panic-recovery) is thorough.None of the above findings are release-blocking crashes in the tested/documented usage path — they're hardening gaps and a data race in an edge case. Happy to re-review on the next push.
Sent by Cursor Automation: Automatic PR verification
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Stale comment
Automated verification (
/verification) — re-run on new commitApproval policy: this automation auto-approves only when a PR is a pure dependency bump (
go.mod/go.sumonly, no other files touched). This PR's diff (base0980355→ head6fc3dcf) touchespkg/app/options.go,pkg/app/app_test.go,pkg/mocks/workflow.go, andpkg/workflow/*.go— core engine code — with nogo.mod/go.sumchanges at all. It does not qualify, so it is not auto-approved.CI is green on this exact SHA (build, lint, unit tests with race detector, Windows tests, security-scans, and Snyk code/license/secrets checks all pass), and is treated as the build/test oracle for this run.
Since the previous review (head
258292b→ head6fc3dcf)
- ✅ Fixed: unsynchronized read of
e.loggerin the panic-recovery handler — now goes throughe.GetLogger()like every other access in this file.- ✅ Fixed:
hookEnginenow carriesdefaultCtxFunc: func() context.Context { return hookCtx }, so a hook's nestedInvokecalls inherit the top-level context's values/deadline/cancellation instead of silently losing them. New testTest_PostInvokeHook_ReceivesContextValuescovers this.- ✅ Addressed: the structural-vs-convention recursion guard concern was resolved via the documented alternative fix direction —
PostInvokeHook's doc comment now carries an explicit WARNING that hooks must invoke through the providedengineparameter, not a captured raw engine reference, or the guard is bypassed.- ✅ Fixed: doc comment now explicitly states hooks fire "including when the workflow is not found," clearing up the earlier ambiguity around
GetError()not always being callback-sourced. New testTest_PostInvokeHook_NestedInvokeOfMissingWorkflowalso covers a nested invoke of a missing workflow correctly staying tagged as nested.Still open (non-blocking, informational)
- Adding
AddPostInvokeHookto the exportedEngineinterface is a breaking-ish addition for any out-of-repo implementer ofEngine— matches existing precedent (GetRuntimeInfo/SetRuntimeInfo) and GAF intentionally stays on major version 0, so not a novel violation.- Post-invoke hooks are still silently skipped if
Invokeis called beforeInit()(early-return path predates the hook-firing block). Low real-world impact since production engines are alwaysInit()'d first.- Minor: unlike
defaultCtxFunc(now fixed above),hookEnginestill doesn't setdefaultInstrumentationCollector, so a hook-initiated nestedInvokewon't inherit the top-level instrumentation collector the way a workflow-side nested call would.- Suggestion (unchanged):
PostInvokeContextis modeled as an interface with a single implementation and an unused generated mock — a plain exported struct would cover current callers with less indirection.Security
No exploitable findings. No dependency-manifest changes to scan. Hooks are registered only by trusted host code, not workflow extensions, so no new attacker-reachable surface.
Note on this automation's prior reviews
This automation previously left two
CHANGES_REQUESTEDreviews on this PR. Per current policy, this automation will not request changes on any future run — findings are reported as comments only, and this PR is never blocked by this automation. The available tooling can only dismiss this automation's own prior approvals, notCHANGES_REQUESTEDreviews, so those two reviews remain visible on the PR; a maintainer with write access would need to dismiss them manually from the GitHub UI if a clean review state is desired.None of the remaining findings are release-blocking in the tested/documented usage path. Happy to re-review on the next push.
Sent by Cursor Automation: Automatic PR verification
all findings should be addressed
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
8139b19 to
e63d6ff
Compare
This comment has been minimized.
This comment has been minimized.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
e63d6ff to
2c1f800
Compare
This comment has been minimized.
This comment has been minimized.
| } | ||
|
|
||
| if !options.nested { | ||
| e.firePostInvokeHooks(hookCtx, id, output, err, options.ic) |
There was a problem hiding this comment.
question/issue: I think this is missing out on the config that can be passed though
go-application-framework/pkg/workflow/engineimpl.go
Lines 49 to 53 in de09b2b
Was this by design?
| output: invokeOutput, | ||
| err: invokeErr, | ||
| } | ||
| hookEngine := &engineWrapper{WrappedEngine: e, defaultCtxFunc: func() context.Context { return ctx }, defaultInstrumentationCollector: ic} |
There was a problem hiding this comment.
question: So hookCtx has the timeout, but hookEngine defaults nested invocations to the original ctx. In this case, doesn't eng.Invoke(...) continue after the hook timeouts? Is that intentional, or should hookEngine also use hookCtx?
|
/describe |
|
PR Description updated to latest commit (2c1f800) |
The per-subtest isolation loop cleared only the six proxy variables, but the test also asserts on NODE_EXTRA_CA_CERTS, KRB5_CONFIG and KRB5CCNAME. Those three were read from the ambient environment, so every subtest expecting them empty failed on any machine that sets them. Clear all nine variables DetectProxyConfig reads, keeping the list in step with envVarSpecs in constants.go. [CLI-1743]
Address review findings on the post-invoke hooks feature. AddPostInvokeHook checked e.initialized outside e.mu and appended under it, while Init stored the flag outside e.mu entirely. A concurrent Init could land between the check and the append, registering a hook after initialization and violating the documented contract. Both the check and the store now happen under e.mu; Invoke's read stays lock-free and Init still runs extension initializers unlocked, so registration from an initializer cannot deadlock. The hook timeout was a package-level mutable var that tests reassigned. It now comes from Configuration via POST_INVOKE_HOOK_TIMEOUT, typed as a time.Duration like CONFIG_CACHE_TTL, defaulting to the previous 5s so existing callers are unaffected. Consumers with different budgets can set their own. A hook that exceeds the timeout is abandoned and keeps running after Invoke returns, which races with caller-owned state - confirmed with the race detector. That behaviour is unchanged by design, but it was undocumented and invisible: the PostInvokeHook contract now states it, and the timeout warning reports how many hooks were still running. [CLI-1743]
|
/describe |
|
PR Description updated to latest commit (d6aea66) |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d6aea66. Configure here.
This comment has been minimized.
This comment has been minimized.
firePostInvokeHooks read POST_INVOKE_HOOK_TIMEOUT from the engine-wide Configuration, but Invoke resolves a per-invocation config into options.config - a clone of the engine config by default, replaced outright when the caller passes WithConfig. A timeout set on a config handed to WithConfig was therefore ignored, while the workflow and network layers honoured that same config. Thread options.config into firePostInvokeHooks and resolve the timeout from it, falling back to the engine config when it is nil. The nil case is real: options.config is only defaulted inside the callback branch, so it is unset on the workflow-not-found path, which also fires hooks. Reported by Cursor Bugbot and a reviewer on d6aea66. [CLI-1743]
|
/describe |
This comment has been minimized.
This comment has been minimized.
|
PR Description updated to latest commit (9a17941) |
Init runs each extension initializer in turn and returns early when one errors, leaving initialized false and nothing unwound. Because AddPostInvokeHook only appends, a caller who retries Init re-runs every initializer and re-registers the hooks the earlier attempt had already added, so each top-level Invoke ran them twice. Snapshot the hook count before the initializer loop and truncate back to it when an initializer fails, so each attempt contributes its hooks at most once. The snapshot is taken rather than clearing outright because hooks registered before Init - the pkg/app WithPostInvokeHooks path - must survive the unwind. The truncation takes e.mu, but the initializer loop still runs unlocked, so an initializer registering a hook cannot deadlock against it. Reported by Cursor Bugbot. [CLI-1743]
|
/describe |
|
PR Description updated to latest commit (dba54d6) |
PR Reviewer Guide 🔍
|
|
/describe |
|
PR Description updated to latest commit (dba54d6) |



User description
Description
This PR introduces a capability to register and run post invoke hooks.
Checklist
make test)make generate)make lint)go get github.com/snyk/go-application-framework@YOUR_LATEST_GAF_COMMITin thecliv2directory.go.modto point to your local GAF code.go mod tidyin thecliv2directory.go.modandgo.sumchanges.Note
Medium Risk
Touches the central
Invokepath (panic recovery, hook goroutines, timeout) so regressions could affect every workflow run; behavior is heavily tested and hooks are opt-in registration.Overview
Adds post-invoke hooks that run after each top-level
Engine.Invokecompletes, including when the workflow is missing or the callback returns an error.Hooks receive an
InvokeOutput(workflow id, data, error) and a scopedEngine. Registration is viaAddPostInvokeHook/PostInvokeHookRegistrar, withworkflow.AddPostInvokeHookand appWithPostInvokeHooks. Hooks must be registered beforeInit; late registration errors.Nested invokes (sub-workflows via
engineWrapper/invocation.GetEngine()) do not fire hooks; the wrapper injectswithNested(). Hooks that invoke workflows through the engine argument stay nested and avoid recursion.Hooks run concurrently with a shared 5s timeout context; slow hooks are logged and
Invokedoes not wait indefinitely. Hook and callback panics are recovered so other hooks still run and panics surface as errors to hooks before the original panic is re-raised.initializedonEngineImplis nowatomic.Bool. Mocks addInvokeOutputandPostInvokeHookRegistrar.Reviewed by Cursor Bugbot for commit 2c1f800. Bugbot is set up for automated code reviews on this repo. Configure here.
PR Type
Enhancement, Bug fix
Description
Introduce post-invoke hooks for engine invocations.
Hooks run concurrently after top-level invokes.
Skip hooks for nested invocations.
Fix hook registration race condition.
Add configurable timeout for hooks.
Diagram Walkthrough
flowchart LR A[Engine.Invoke] --> B{Workflow Found}; B -- Yes --> C[Workflow Callback]; B -- No --> D[Invoke Error]; C --> E(Post Invoke Hooks); D --> E; E -- Concurrent, Timed --> F[Hook Completion]; A -- Nested Invoke --> G[Engine.Invoke (Nested)]; G --> C; G -- Hooks Skipped --> F;File Walkthrough
4 files
Add test for WithPostInvokeHooks optionAdd mocks for InvokeOutput and PostInvokeHookRegistrarAdd comprehensive tests for post-invoke hooksAdd test for hook recursion guard5 files
Add WithPostInvokeHooks option for app engineAdd POST_INVOKE_HOOK_TIMEOUT constantImplement post-invoke hook execution and timeoutMark wrapper invokes as nested to skip hooksDefine InvokeOutput, PostInvokeHook types and Registrar1 files
Improve test isolation for proxy config detection