fix: resolve activity inputs in the workflow before scheduling by YJ-928 · Pull Request #615 · zigflow/zigflow · GitHub
Skip to content

fix: resolve activity inputs in the workflow before scheduling - #615

Merged
mrsimonemms merged 1 commit into
zigflow:mainfrom
YJ-928:fix/462-resolve-activity-inputs-once
Oct 8, 2026
Merged

mrsimonemms merged 1 commit into
zigflow:mainfrom
YJ-928:fix/462-resolve-activity-inputs-once

Conversation

@YJ-928

@YJ-928 YJ-928 commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Description

Activities received their inputs as unresolved ${ ... } strings and evaluated them themselves, so the history recorded expressions instead of the values the activity used.

Inputs are now resolved once, in the workflow, before the activity is scheduled. The activity evaluates only what the workflow explicitly leaves for it.

What is resolved: exactly the fields each activity evaluated before, with the same coercions.

Task Resolved before scheduling
call: http with
call: grpc method, arguments
run: shell, run: script list-form arguments, environment, a script's external source
run: container arguments, environment, and image on Docker (the Kubernetes runtime never evaluated it)

call: activity and run: workflow already resolve in the workflow and are unchanged.

The $data.activity boundary. $data.activity describes the attempt that is running: attempt, start time, deadline, task token. One scheduled input serves every retry attempt, so it cannot be known before scheduling.

  • An expression that may read it is left as written. Its location is recorded as a JSON Pointer in ActivityInputs.Deferred on the state the activity receives, and the activity evaluates those locations and nothing else.
  • Detection uses the parsed jq expression and fails closed: $data counts unless its first access is a static key other than activity. So $data | .activity.attempt and $data as $d | $d.activity.attempt are deferred.
  • A false positive only defers an expression that the activity then evaluates against the same state plus the activity metadata.
  • The gRPC activity never adds activity metadata, so $data.activity is resolved there like anything else; only values JSON cannot carry (below) are deferred.

Evaluated once. A resolved value that itself looks like an expression reaches the endpoint as text, for example $input.comment holding "${ $env.API_TOKEN }". Nothing evaluates it again, and nothing is quoted.

Values JSON cannot carry. Temporal decodes activity arguments as JSON, where every number is a float64. An expression whose result contains an integer beyond 2^53, NaN or infinity is therefore left for the activity, which evaluates it exactly as before. Otherwise a body or gRPC argument such as ${ 9007199254740993 } would arrive as 9007199254740992.

Compatibility

  • New executions record a zigflow.resolve-activity-inputs version marker, using the same workflow.GetVersion pattern as zigflow.per-task-activity-aliases. An execution without the marker keeps scheduling unresolved inputs, and the activities still evaluate those, so open histories replay unchanged.
  • I checked this live. An execution started on main was handed to this branch's worker during a wait. It replayed, finished its remaining activities on the old path, and completed with no non-determinism errors.
  • Resolution runs in workflow code, so it walks map keys in sorted order: with several failing expressions the reported error, and the list of deferred locations, are the same on every run and replay.
  • Non-deterministic expressions in these inputs are already rejected by zigflow validate and when the worker loads the workflow (checked for http headers, shell arguments and container environment). Evaluating them in workflow code is therefore replay-safe.
  • Environment values are not newly exposed: the ZIGGY_* environment is already part of the state every activity receives.

Behaviour changes

  • An expression in these inputs that fails to evaluate now fails the task before anything is scheduled. Before, it failed the activity, and for http and run every retry the retry policy allowed. A try still catches it; $data.error has no activity block because no activity ran.
  • During a rolling upgrade, an activity scheduled by an upgraded worker but executed by one that is not yet upgraded is evaluated the old way. In that window, a resolved value that looks like an expression would be evaluated again.

Related issue

Fixes #462

How to test

go test ./pkg/...

docker compose -f tests/e2e/compose.yaml up -d --wait
export TEMPORAL_ADDRESS=$(docker compose -f tests/e2e/compose.yaml port temporal 7233)
export ZIGGY_HTTP_MOCK=$(docker compose -f tests/e2e/compose.yaml port http_mock 8888)
go test -v -tags=e2e ./tests/e2e/...

New tests:

  • pkg/utils/activity_inputs_test.go: the detection table, including the forms from the Evaluate expressions before executing activities #483 review; deferred locations, including results JSON cannot carry; values evaluated once; the same error reported every time when several expressions fail; and parity with TraverseAndEvaluateObj for activities scheduled before this change.
  • pkg/zigflow/tasks/task_builder_activity_inputs_test.go: what http, grpc, shell, script and container (Docker and Kubernetes) are scheduled with; a failing expression; an execution without the version marker.
  • pkg/zigflow/activities/activity_inputs_test.go: the http, shell, Docker and Kubernetes paths evaluate only the deferred locations, including when nothing is deferred. Old-vs-new tests run a matrix of values (small and large integers, floats, booleans, null, nested values, numbers from $input) through both paths and Temporal's real data converter, and check that the HTTP request, the gRPC arguments and the shell output are unchanged.
  • e2e activity-inputs checks two things:
    • The history records the resolved with and the deferred list.
    • A shell echo of a resolved ${ $env.HTTP_MOCK } and a deferred attempt prints ${ $env.HTTP_MOCK } 1.

Checklist

  • This PR links to an issue using Fixes #..., Closes #... or Relates to #...
  • All tests pass. tests/chart was not run locally because it needs helm; the chart is untouched.
  • Tests have been added or updated where behaviour changed
  • Documentation has been updated where needed

@mrsimonemms mrsimonemms added the ai-review Trigger an AI code review for this PR label Oct 8, 2026
@YJ-928
YJ-928 force-pushed the fix/462-resolve-activity-inputs-once branch from a61b82e to b4d7734 Compare October 8, 2026 10:21
@YJ-928

YJ-928 commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Good catch, fixed in the same commit: map keys are now walked in sorted order, so the first error and the deferred list are deterministic on replay (the extra sort of Deferred went with it). Added TestResolveActivityInputsReportsTheSameErrorEveryTime, which resolves several failing expressions in both map shapes repeatedly and fails against unsorted iteration.

@mrsimonemms mrsimonemms added ai-review Trigger an AI code review for this PR and removed ai-review Trigger an AI code review for this PR labels Oct 8, 2026
@zigflow zigflow deleted a comment from github-actions Bot Oct 8, 2026
Activities received their inputs as unresolved ${ ... } strings and
evaluated them themselves, so the history recorded the expressions rather
than the values the activity used.

The workflow now resolves the inputs of call: http, call: grpc and run
(shell, script and container) before scheduling the activity: exactly the
fields each activity evaluated before, with the same coercions. The
activity evaluates only the expressions the workflow left for it, listed by
JSON Pointer on the state it receives: those that read $data.activity,
which exists only once an attempt runs, and those whose result JSON cannot
carry exactly (an integer beyond 2^53, NaN or infinity), since Temporal
decodes activity arguments as JSON numbers. Every expression is evaluated
once, so resolved data that looks like an expression reaches the activity
as text. Map keys are walked in sorted order, so the first error and the
deferred list are the same on replay.

New executions record the zigflow.resolve-activity-inputs version marker.
An execution without it keeps scheduling unresolved inputs, and the
activities still evaluate those, so open histories replay unchanged.

Fixes zigflow#462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yash B Joshi <68319416+YJ-928@users.noreply.github.com>
@YJ-928
YJ-928 force-pushed the fix/462-resolve-activity-inputs-once branch from b4d7734 to 14d03a2 Compare October 8, 2026 12:04
@YJ-928

YJ-928 commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Good catch, fixed in the same commit, as a class rather than the one case. An expression whose result JSON can't carry exactly (an integer beyond 2^53, NaN, infinity) is now left for the activity, as before this PR. The same precision loss would have hit gRPC arguments through Temporal's decoding, so that's covered too.

New regression tests run the old and new paths over a matrix of value shapes through Temporal's data converter, and check that the actual HTTP request, gRPC arguments and shell output are unchanged. They fail without the fix for both HTTP and gRPC.

@zigflow zigflow deleted a comment from github-actions Bot Oct 8, 2026
@mrsimonemms mrsimonemms added ai-review Trigger an AI code review for this PR and removed ai-review Trigger an AI code review for this PR labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Codex PR Review

Automated review note: Zigflow uses AI-assisted PR review tooling to help identify potential bugs, regressions and edge cases.
These comments are intended as supplemental review feedback only and may occasionally be incorrect or overly cautious.
Please treat them as additional input for discussion rather than authoritative project direction.

No blocking issues. Ship it.

Tests could not be run in this read-only environment because Go could not create its module cache.

@mrsimonemms mrsimonemms left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the workflow resolution, deferred activity metadata, JSON precision handling, shared task isolation and version-gated legacy path. No blocking findings.

Validation: go test ./pkg/... passes locally with TMPDIR=/private/tmp (the default macOS temporary path causes an existing /var vs /private/var assertion mismatch). The reviewed commit also has passing CI unit tests, race tests, end-to-end tests, linting and Helm checks.

The mixed-worker upgrade caveat documented in the PR remains: an older activity worker can re-evaluate resolved text that resembles an expression.

@mrsimonemms
mrsimonemms added this pull request to the merge queue Oct 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Oct 8, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@mrsimonemms

Copy link
Copy Markdown
Collaborator

Thank you @YJ-928. I’ve really appreciated your contributions and how you handle reviews. Would you be interested in joining me in more of a maintainer role for the Zigflow project?

@mrsimonemms
mrsimonemms merged commit 7247604 into zigflow:main Oct 8, 2026
23 checks passed
@YJ-928

YJ-928 commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Thank you, @mrsimonemms — that genuinely means a lot, and I'd be glad to accept.

Working on Zigflow with you has been a real pleasure. Your reviews are fast, clear and fair, and I've learned a lot from how you run the project. We run Zigflow in production every day, so I care about it being solid, and I'd like to help carry more of the load.

Where I think I can help most:

  • Reviewing PRs, especially around activities and expression evaluation, fork/try and cancellation, and listen/HITL, the areas I know best from the last few PRs.
  • Triaging issues: reproducing reports, narrowing them to a minimal workflow, and labelling them so they're ready to pick up.
  • Answering questions in Discussions and on issues.
  • Continuing to send fixes, starting with the macOS temp-path assertion you hit while validating this PR (/var vs /private/var), so go test ./pkg/... passes without setting TMPDIR.

To set expectations honestly, I can reliably commit around 5 hours a week. That's enough to review PRs and respond to new issues within two working days, with more time when something is urgent or a release is close.

A few questions so I can fit in the way you'd like:

  1. What responsibilities do you have in mind: reviews, triage, merging, releases?
  2. What permissions would come with it, and are there rules you'd like me to follow? For example, when I may approve or merge, and when a PR always needs your review.
  3. Is there a roadmap or set of priorities I should focus on first?
  4. Where would you like to discuss maintainer matters day to day: here on GitHub, or somewhere else?

Thanks again for the trust. Looking forward to it.

@mrsimonemms

Copy link
Copy Markdown
Collaborator

That's great news @YJ-928, thank you. Your "where I can help" list is a great start. My overall intention is to make Zigflow sustainable in the long-term - ie, Zigflow != Simon. Five hours a week would be great. Honestly, my take is that two people doing stuff is better than one (and three will be better than two and so on) so whatever you can do in the spirit that you have been doing would be wonderful.

My main ambition is getting Zigflow to v1 status, which involves getting the identified issues resolved (there may be more, but this is what we know about today). In answer to your questions:

  1. whatever you feel comfortable with. As a solo maintainer, these were all on me. With a second, I'd prefer for all maintainers to be equals - I'd be happy to give you rights for those things, but we can keep open dialogue between us for when we're unsure. I suspect that a release should probably be a discussion (and that includes me too). Short answer is that we do our best and assume good intentions and work it out as we go along.
  2. following from above. I'm happy to give you full maintainer permissions. You've already proven that you care about the project and deserve trust, so I'm happy to continue in that spirit.
  3. yes, see the v1 issues above
  4. let's use the Zigflow Slack channel. If we need private discussions, we can deal with that when we need to

@mrsimonemms

Copy link
Copy Markdown
Collaborator

Outside of this project, another priority is getting Zigflow Studio into a releasable state. That may or may not interest you, but there's definitely a desire for that project.

@YJ-928

YJ-928 commented Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

Thanks Simon, that's really clear and it's great to have it laid out. Equal maintainers, open discussion when unsure, and releases as a discussion all sound right to me.

I'll join the Zigflow Slack channel today. On the v1 list, my plan is:

  1. The macOS /private/var test fix I mentioned, first.
  2. Enforce document.dsl Version Validation #491 (enforce document.dsl version validation) as my first v1 PR.
  3. for and try blocks compile to child workflows — fan-in read/lock amplification on hot parents at scale #525 next. I've hit the child-workflow fan-in cost of for/try in production, so I'll post a proposed approach on the issue before writing any code, so we can agree the design first.

On Zigflow Studio: thanks for thinking of me. I need to check one thing on my side before I pick anything up there, and I'll come back to you on it.

Looking forward to getting Zigflow to v1 together.

@mrsimonemms

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Trigger an AI code review for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Activity inputs should be expression-evaluated before scheduling activities

2 participants