Temporal cluster: merge #78/#79/#81 + cross-language Java→Go join (#77) + const retention (#80)#82
Merged
Conversation
…larations Surface workflow.SetQueryHandler / GetSignalChannel / SetUpdateHandler[WithOptions] calls as via=temporal.handler EdgeCalls edges carrying temporal_kind (query/signal/update) + temporal_name, originating from the enclosing workflow function. This mirrors the Java side's per-method @QueryMethod / @SignalMethod / @UpdateMethod annotation edges, giving the graph a symmetric, queryable record of the named handlers each Go workflow exposes. High-precision: only the canonical "workflow" receiver alias is matched and the handler name must be a string literal (runtime-matched names can't be pinned from a variable), consistent with the existing dispatch detector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault vars
When a workflow names its activity/child-workflow through a local variable
read from an env var with a literal fallback, resolve the dispatch to that
literal default instead of leaving it unresolved:
actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard")
workflow.ExecuteActivity(ctx, actName, id) // -> activity "ChargeCard"
name := os.Getenv("K"); if name == "" { name = "ChargeCard" }
The parser does a narrow, intra-procedural lookup anchored on a literal
os.Getenv / os.LookupEnv read (so the value is provably env-sourced, not a
general data-flow guess) and tags the stub edge temporal_name_origin=
env_default. The resolver then lands the edge at the speculative tier
(OriginSpeculative, confidence 0.4, MetaSpeculative=true) rather than
ast_resolved — the runtime env override may name a different handler than
the literal default, so the edge is present but hidden from default queries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nnelWithOptions Complete the in-workflow handler set: the detector already handled SetUpdateHandlerWithOptions but missed the WithOptions variants of the query and signal handlers. Note: the Go SDK has no SetSignalHandler — signals are received via GetSignalChannel[WithOptions]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing workflows
Surface the consumer side of the Temporal signal/query namespaces:
workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // workflow -> workflow
client.SignalWorkflow(ctx, wid, rid, "name", arg) // service -> workflow
client.QueryWorkflow(ctx, wid, rid, "name", args...) // service -> workflow
Each emits an EdgeCalls edge tagged via=temporal.signal-send /
temporal.query-call carrying temporal_kind + temporal_name (the signal /
query name = the 4th positional argument, a string literal). These pair
with the in-workflow handler edges (via=temporal.handler) as the
sender/handler two sides of the signal/query contract.
SignalExternalWorkflow is gated on the canonical "workflow" receiver;
SignalWorkflow / QueryWorkflow live on the client and are matched by
method name (like the Register* helpers), kept high-precision by the
string-literal name gate. No new edge kinds — consistent with the
existing EdgeCalls + via=temporal.* convention.
Note on the Go SDK surface: there is no workflow.SetSignalHandler
(signals are received via GetSignalChannel), no workflow.QueryWorkflow
(querying is client-side), and no SignalExternalWorkflowAsync
(SignalExternalWorkflow already returns a Future).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* pr79-review: feat(temporal): resolve activity/workflow names from env-var-with-default vars
* pr78-review: feat(temporal): also detect SetQueryHandlerWithOptions / GetSignalChannelWithOptions feat(temporal): detect Go in-workflow query/signal/update handler declarations # Conflicts: # internal/indexer/temporal_e2e_test.go # internal/parser/languages/go_temporal_test.go # internal/parser/languages/golang.go # internal/parser/languages/golang_temporal.go
* pr81-review: feat(temporal): detect outbound signal-send / query-call against running workflows # Conflicts: # internal/parser/languages/go_temporal_test.go # internal/parser/languages/golang.go # internal/parser/languages/golang_temporal.go
goCallEnvDefaultLiteral modelled cmp.Or backwards (returned the last
string literal; cmp.Or returns the FIRST non-zero argument) and accepted
any callee that mixed an env read with a literal, so an arbitrary user
wrapper like combine(os.Getenv("K"), "Suffix") was misread as an
env-or-default. It now returns the first literal and is gated on the
stdlib cmp.Or callee.
goTemporalEnvDefaultName took the first matching assignment in tree order
and descended into nested closures, so a later live reassignment was
ignored and a shadowing same-named variable in an inner func literal
could mis-flag the outer dispatch. It now replays the writes in source
order (last live write wins) and never descends into nested func
literals.
Adds regression tests: cmp.Or multi-literal first-wins, non-cmp.Or callee
rejected, env-default overwritten by a later reassignment, and a
shadowing closure variable not matched.
PR #81 shipped only parser-level tests; this exercises SignalExternalWorkflow and client.QueryWorkflow through the full indexer pipeline, asserting the via=temporal.signal-send / temporal.query-call edges originate from the sender function and carry the correct temporal_kind + temporal_name.
idx.lookup keyed only on <kind>::<name> with no language filter, and the candidate set co-mingles Go register targets with Java annotation-tagged methods. A Go workflow.ExecuteActivity stub could therefore resolve onto a Java method node when names collided and the Java entry was the unique overall candidate (pickGoTemporalTarget gates language only on the Go register-indexing path, not the stub-resolution path). lookup now filters candidates to the caller's language; the intentional Java->Go join is a separate cross-language pass. Adds a unit test for the gate.
…e-back ResolveTemporalCalls runs on every incremental single-file reindex and scanned the largest edge class (EdgeCalls) three times per pass (once for register edges in buildTemporalIndex, once for the EdgeAnnotated probe, once for stub edges) plus an unconditional AddNode for every temporal-role node — a whole-workspace recompute paid even by repos with no Temporal code. - Collect temporal.register and temporal.stub edges in a single EdgeCalls sweep and pass them into buildTemporalIndex (no second scan). - Probe EdgeAnnotated once and early-out before any node fetch / index build / Java propagation when the graph has zero temporal edges. - stampTemporalRole now skips the store write-back when the node already carries the same role + name, eliminating the per-pass re-write storm.
goTemporalRegisterName returned only the function-reference identifier, so
RegisterActivityWithOptions(MyActivity, activity.RegisterOptions{Name:
"Override"}) indexed the activity under "MyActivity" and a dispatch of
ExecuteActivity(ctx, "Override", ...) never resolved.
The parser now extracts the RegisterOptions{Name: "..."} literal into
temporal_registered_name; the resolver locates the node by the func-ref
name but stamps + indexes it under the registered name, so the dispatch
matches. Covers RegisterActivityWithOptions and RegisterWorkflowWithOptions
(by-value or &-pointer options literal).
goTemporalRegisterKind flagged RegisterActivities as plural and its doc
promised the resolver would promote each method of the struct, but the
parser discarded the flag and the resolver had no plural handling, so
w.RegisterActivities(&MyActivities{}) registered nothing resolvable.
The parser now extracts the struct TYPE name from the &T{} / T{} argument
and tags the register edge temporal_register_plural. The resolver locates
the Go type node and promotes every exported method (via its EdgeMemberOf
members) to an activity keyed by the method name, so a dispatch of
ExecuteActivity(ctx, "ChargeCard", ...) resolves to (*Activities).ChargeCard.
Unexported methods are skipped. Adds an indexer e2e test.
…mily Adds detection of client.ExecuteWorkflow(ctx, opts, workflow, args...) (workflow at position 3) and client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, ...) (workflow at position 6 — distinct from the 4th-position signal-name family). Each emits a via=temporal.start EdgeCalls edge carrying temporal_kind=workflow + temporal_name (the workflow func ref, selector, or string type name). ResolveTemporalCalls now consumes temporal.start edges alongside temporal.stub and rewrites them to the registered workflow node, so get_callers / impact on a Go workflow surfaces the services that start it — the 'who starts this workflow' relationship. Parser + indexer e2e tests.
The dispatch / handler / SignalExternalWorkflow detectors gated on the receiver text being literally "workflow", so a file using import wf "go.temporal.io/sdk/workflow" lost dispatch AND handler AND signal-send detection at once. The extractor now resolves the workflow package's local alias from the file's import table and canonicalises a matching receiver to "workflow" before the receiver-gated detectors run. A non-workflow receiver that merely shares a name is unaffected. The three *AliasedNotDetected tests become *Detected, plus a negative test that a non-workflow receiver is still ignored when workflow is aliased.
The new provider/consumer edge tags (temporal.handler / signal-send / query-call / start) and the register variants had no discoverable documentation. Adds a Temporal edge-taxonomy table to docs/contracts.md (via, direction, source call, temporal_kind, resolved?) plus the extra Meta (temporal_registered_name, temporal_register_plural, temporal_name_origin) and roles, and lists the temporal via values + the temporal_role Meta field in the gortex://schema MCP resource.
…atch Real codebases name activities almost exclusively through string consts (const ChargeCardActivity = "ChargeCard"; ExecuteActivity(ctx, ChargeCardActivity, ...)), but emitConst dropped the literal so the dispatch resolved against the identifier and never matched the registered activity. Adds a queryable constant_values sidecar (node_id, repo_prefix, file_path, value) — an optional ConstantValueWriter/Reader capability implemented by both the in-memory and sqlite backends, mirroring the clone_shingles / ref_facts pattern. Kept out of the gob Meta blob (unindexable, decoded on every node load) per the design steer. The Go extractor records each KindConstant's string/numeric literal; the indexer persists them per file (repo-prefix-aware, file-scoped eviction on reindex). ResolveTemporalCalls builds a name→value deref map from the sidecar and, when a dispatch name doesn't resolve directly, retries under the const's literal value, recording temporal_const_deref on the edge. Ambiguous const names (same name, different values) are dropped so a deref is never a wrong guess. Cross-file e2e + sqlite capability unit tests.
The Java side indexed methods under their bare identifier, while the Go side registers under the function / type name and the real Temporal wire name follows the SDK defaults — so the two never lined up for a cross-language join. The resolver now computes each Java method's canonical Temporal name and indexes / stamps it under that string: an explicit @XxxMethod(name=) wins (parsed from the EdgeAnnotated args); an activity defaults to its method name capitalized, prefixed by @ActivityInterface(namePrefix=); a workflow's type is the interface simple name; signal/query/update keep the method name. This is the prerequisite that makes a name-based Java<->Go match correct. Adds helper unit tests and canonical-name assertions to the Java propagation tests.
Closes the polyglot gap: a Java service that starts a workflow implemented
in a Go repo is now linked to the Go workflow node.
Java side: the extractor detects client.newWorkflowStub(OrderWorkflow.class,
...) / newUntypedWorkflowStub("OrderWorkflow") and emits a via=temporal.start
consumer edge keyed by the workflow's canonical type name (the class
literal's simple name, matching the Java SDK default and the name a Go
RegisterWorkflow uses).
Resolver side: when a consumer (temporal.start / stub) has no same-language
handler, lookupCrossLang matches a unique candidate in a DIFFERENT language
by canonical name and lands the edge at the speculative tier
(OriginSpeculative, conf 0.4, MetaSpeculative + temporal_cross_lang) — a
by-name match across a type-system boundary, hidden from default queries.
Builds on the G2 canonical names so the strings line up. get_callers /
impact on a Go workflow now surface the Java services that start it.
Parser, resolver, and Go+Java e2e tests.
Mirrors the Go outbound signal/query detection (#81) on the Java side so the polyglot estate is symmetric: an outbound stub.signal("name", …) / stub.query("name", …) on an untyped WorkflowStub emits a via=temporal.signal-send / temporal.query-call edge carrying the signal/query name (the first string-literal argument). Because "signal" / "query" are ordinary method names, detection is gated on the receiver's inferred type being WorkflowStub (via the extractor's type environment), so it never false-matches arbitrary code. Parser tests cover the WorkflowStub case and a negative for a non-stub receiver.
A function that forwards one of its parameters as the dispatch name —
executeActivity(ctx, ao, name, …) { workflow.ExecuteActivity(ctx, name, …) }
— is a dispatch wrapper. The parameter is not a real activity name, so the
stub it produced could never resolve and was pure noise.
The extractor now recognises this shape (the dispatch name is one of the
enclosing function's parameters), suppresses the unresolvable stub, and
stamps temporal_wrapper_kind / temporal_wrapper_param on the function node
so a future interprocedural pass can propagate the caller's argument.
Needed a full param-name set per function (paramsByFunc keeps only
non-builtin-typed params), threaded as paramNamesByFunc.
NOTE: cross-file wrapper-FOLLOWING — propagating a caller's literal/const
through the wrapper to the real handler — requires call-argument flow and
remains a documented blind spot (the const-deref already covers the
const-naming half of the problem). Adds a wrapper-suppression test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Integrates PRs #78, #79, #81 and implements the design work from issues #77 and #80, end-to-end. Instead of merging the three PRs as-is, they were brought onto one branch, their defects fixed, and the cross-cutting Temporal intelligence built on top — including the cross-language Java→Go workflow join (#77) and constant-value retention (#80 Q1).
All packages build (incl. CGO binary),
go vetis clean, andgo test -racepasses forinternal/graph,internal/graph/store_sqlite,internal/resolver,internal/parser/languages, andinternal/indexer.Closes the work tracked in #77 and #80; supersedes #78, #79, #81 (their commits are included).
What's in here
Merged PRs (clean-integrated)
SetQueryHandler/GetSignalChannel/SetUpdateHandler→via=temporal.handler). Merged as-is; SDK surface verified correct.SignalExternalWorkflow/client.SignalWorkflow/client.QueryWorkflow→via=temporal.signal-send/temporal.query-call). Merged + an indexer e2e test added (it shipped parser-only).Fixes to the merged PRs
fix(temporal): correct env-default name resolution data-flow):cmp.Orwas modelled backwards (returned the last string literal;cmp.Orreturns the first non-zero) and accepted any callee mixing an env read with a literal. Now returns the first literal, gated on thecmp.Orcallee. The lexical walk took the first match and descended into nested closures; now replays writes in source order (last live write wins) and never descends into nestedfuncliterals.Correctness / performance (independent of the PRs)
idx.lookuphad no language filter, so a Gotemporal.stubcould resolve onto a Java method node on a name collision. Now gated by caller language; the intentional cross-language join is a separate, explicit path.ResolveTemporalCallsperf — it runs on every incremental edit and scanned the largest edge class (EdgeCalls) three times per pass plus an unconditionalAddNodeper role. Now: oneEdgeCallssweep, an early-out before any work when the graph has no Temporal edges, and conditional role write-back.Completed Go surface
RegisterActivityWithOptions{Name}override honored (the canonical dispatch name).RegisterActivities(&Struct{})struct registration — promotes every exported method to an activity.client.ExecuteWorkflow(workflow @ arg 3) andSignalWithStartWorkflow(workflow @ arg 6) →via=temporal.start, resolved to the registered workflow ("who starts this workflow").import wf "…/workflow"— the receiver-gated detectors now canonicalise the file's workflow alias.via=temporal.*taxonomy indocs/contracts.md+ thegortex://schemaMCP resource.#80 Q1 — constant-value retention + dereference
The dominant real-world shape (
const ChargeCardActivity = "ChargeCard"; ExecuteActivity(ctx, ChargeCardActivity, …)) now resolves. Per the design steer, the literal is kept in a queryableconstant_valuessidecar (a new optionalConstantValuecapability on both the in-memory and sqlite backends, mirroringclone_shingles/ref_facts) — not the gobMetablob. The resolver builds a name→value deref map and retries unresolved dispatch names under the const value (ambiguous names dropped, never a wrong guess).#77 — cross-language Java→Go join
@XxxMethod(name=)> activityCapitalize(method)+@ActivityInterface(namePrefix=)> workflow = interface simple name) so the strings line up with the Go side.client.newWorkflowStub(OrderWorkflow.class, …)/newUntypedWorkflowStub("…")and emits avia=temporal.startconsumer edge; the resolver'slookupCrossLangmatches a unique other-language candidate by canonical name and lands the edge at the speculative tier (OriginSpeculative/MetaSpeculative/temporal_cross_lang).get_callers/ impact on a Go workflow now surface the Java services that start it.WorkflowStubtype to stay precise.#80 Q2 — wrapper detection (bounded)
A function forwarding a parameter as the dispatch name (
executeActivity(ctx, ao, name, …){ ExecuteActivity(ctx, name, …) }) is now recognised as a dispatch wrapper: the unresolvable parameter-named stub is suppressed (it was pure noise) and the function is marked (temporal_wrapper_*). Documented blind spot: cross-file wrapper-following (propagating a caller's literal/const through the wrapper) needs call-argument flow and is deliberately deferred — the const-deref already covers the const-naming half.Design decisions (re: #77 / #80)
EdgeCalls+via=temporal.*(no new edge kinds) so blast-radius / impact traverse generically.ast_resolved— a by-name match across a type-system boundary, hidden from default queries.Meta(unindexable gob blob, decoded on every node load) and not extra graph nodes (size).