11// @ts -check
22/// <reference types="@actions/github-script" />
33
4+ const { readExperimentAssignments } = require ( "./experiment_helpers.cjs" ) ;
5+
46/**
57 * Resolves the item type, item number, and comment id from the GitHub Actions
68 * event payload, covering issues, pull requests, discussions, check runs,
@@ -88,6 +90,75 @@ function resolveItemContext(payload) {
8890 return { item_type : "" , item_number : "" , comment_id : "" , comment_node_id : "" } ;
8991}
9092
93+ /**
94+ * Builds a workflow-call identifier for the current workflow invocation.
95+ *
96+ * GitHub reusable workflows share the same run ID as their caller, so the
97+ * workflow ref is appended when available to distinguish parent and child
98+ * workflow invocations inside a single run.
99+ *
100+ * @param {string | number | null | undefined } runId
101+ * @param {string | number | null | undefined } runAttempt
102+ * @param {string | null | undefined } workflowRef
103+ * @returns {string }
104+ */
105+ function buildWorkflowCallId ( runId , runAttempt , workflowRef ) {
106+ const normalizedRunId = String ( runId ?? "" ) . trim ( ) ;
107+ if ( ! normalizedRunId ) {
108+ return "" ;
109+ }
110+
111+ const normalizedRunAttempt = String ( runAttempt ?? "" ) . trim ( ) || "1" ;
112+ const normalizedWorkflowRef = typeof workflowRef === "string" ? workflowRef . trim ( ) : "" ;
113+ const baseId = `${ normalizedRunId } -${ normalizedRunAttempt } ` ;
114+
115+ return normalizedWorkflowRef ? `${ baseId } :${ normalizedWorkflowRef } ` : baseId ;
116+ }
117+
118+ /**
119+ * Parse inbound aw_context from workflow inputs or repository_dispatch payload.
120+ *
121+ * Callers may deliver aw_context as a JSON string (workflow_call/workflow_dispatch)
122+ * or as a plain object (repository_dispatch client_payload).
123+ *
124+ * @param {unknown } raw
125+ * @returns {Record<string, unknown> | null }
126+ */
127+ function parseInboundAwContext ( raw ) {
128+ if ( raw == null ) {
129+ return null ;
130+ }
131+ if ( typeof raw === "string" ) {
132+ const trimmed = raw . trim ( ) ;
133+ if ( ! trimmed ) {
134+ return null ;
135+ }
136+ try {
137+ const parsed = JSON . parse ( trimmed ) ;
138+ if ( parsed && typeof parsed === "object" && ! Array . isArray ( parsed ) ) {
139+ return parsed ;
140+ }
141+ } catch {
142+ return null ;
143+ }
144+ return null ;
145+ }
146+ if ( typeof raw === "object" && ! Array . isArray ( raw ) ) {
147+ return /** @type {Record<string, unknown> } */ raw ;
148+ }
149+ return null ;
150+ }
151+
152+ /**
153+ * Resolve inbound aw_context from the current GitHub payload, if any.
154+ *
155+ * @param {object | null | undefined } payload
156+ * @returns {Record<string, unknown> | null }
157+ */
158+ function readInboundAwContext ( payload ) {
159+ return parseInboundAwContext ( payload ?. inputs ?. aw_context ) || parseInboundAwContext ( payload ?. client_payload ?. aw_context ) ;
160+ }
161+
91162/**
92163 * Builds the aw_context object that identifies the calling workflow run.
93164 * This metadata is injected into dispatched workflows that declare an
@@ -98,7 +169,15 @@ function resolveItemContext(payload) {
98169 * @returns {{
99170 * repo: string,
100171 * run_id: string,
172+ * run_attempt: string,
101173 * workflow_id: string,
174+ * episode_id: string,
175+ * hop_id: string,
176+ * parent_hop_id: string,
177+ * origin_event: string,
178+ * root_repo: string,
179+ * root_workflow_id: string,
180+ * root_run_id: string,
102181 * workflow_call_id: string,
103182 * time: string,
104183 * actor: string,
@@ -111,7 +190,8 @@ function resolveItemContext(payload) {
111190 * workflow_run_conclusion: string,
112191 * otel_trace_id: string,
113192 * otel_parent_span_id: string,
114- * trigger_label: string
193+ * trigger_label: string,
194+ * experiments: string
115195 * }}
116196 * Properties:
117197 * - item_type: Kind of entity that triggered the workflow (issue, pull_request,
@@ -144,19 +224,74 @@ function resolveItemContext(payload) {
144224 * - trigger_label: Name of the label that triggered the workflow for labeled/unlabeled
145225 * events (e.g. pull_request_target, issues, pull_request with labeled type).
146226 * Empty string for events that do not carry label information.
227+ * - experiments: Compact JSON string of the experiment variant assignments picked by
228+ * pick_experiment.cjs for the current workflow run (e.g. `{"caveman":"yes"}`).
229+ * Empty string when no experiments are declared or the assignments file cannot be read.
230+ * Propagated to dispatched child workflows so they can identify which variants the
231+ * parent workflow was running.
147232 */
148233function buildAwContext ( ) {
149234 const { item_type, item_number, comment_id, comment_node_id } = resolveItemContext ( context . payload ) ;
235+ const workflowRef = process . env . GITHUB_WORKFLOW_REF ?? "" ;
236+ const currentRepo = `${ context . repo . owner } /${ context . repo . repo } ` ;
237+ const currentRunId = String ( process . env . GITHUB_RUN_ID ?? context . runId ?? "" ) ;
238+ const currentRunAttempt = String ( process . env . GITHUB_RUN_ATTEMPT ?? "1" ) ;
239+ const currentHopId = buildWorkflowCallId ( currentRunId , currentRunAttempt , workflowRef ) ;
240+ const inheritedContext = readInboundAwContext ( context . payload ) ;
241+ const inheritedHopId = typeof inheritedContext ?. hop_id === "string" ? inheritedContext . hop_id . trim ( ) : typeof inheritedContext ?. workflow_call_id === "string" ? inheritedContext . workflow_call_id . trim ( ) : "" ;
242+ const parentHopId = typeof inheritedContext ?. parent_hop_id === "string" && inheritedContext . parent_hop_id . trim ( ) ? inheritedContext . parent_hop_id . trim ( ) : inheritedHopId ;
243+ const episodeId = typeof inheritedContext ?. episode_id === "string" && inheritedContext . episode_id . trim ( ) ? inheritedContext . episode_id . trim ( ) : inheritedHopId || currentHopId ;
244+ const originEvent =
245+ typeof inheritedContext ?. origin_event === "string" && inheritedContext . origin_event . trim ( )
246+ ? inheritedContext . origin_event . trim ( )
247+ : typeof inheritedContext ?. event_type === "string" && inheritedContext . event_type . trim ( )
248+ ? inheritedContext . event_type . trim ( )
249+ : ( context . eventName ?? "" ) ;
250+ const rootRepo =
251+ typeof inheritedContext ?. root_repo === "string" && inheritedContext . root_repo . trim ( )
252+ ? inheritedContext . root_repo . trim ( )
253+ : typeof inheritedContext ?. repo === "string" && inheritedContext . repo . trim ( )
254+ ? inheritedContext . repo . trim ( )
255+ : currentRepo ;
256+ const rootWorkflowId =
257+ typeof inheritedContext ?. root_workflow_id === "string" && inheritedContext . root_workflow_id . trim ( )
258+ ? inheritedContext . root_workflow_id . trim ( )
259+ : typeof inheritedContext ?. workflow_id === "string" && inheritedContext . workflow_id . trim ( )
260+ ? inheritedContext . workflow_id . trim ( )
261+ : workflowRef ;
262+ const rootRunId =
263+ typeof inheritedContext ?. root_run_id === "string" && inheritedContext . root_run_id . trim ( )
264+ ? inheritedContext . root_run_id . trim ( )
265+ : typeof inheritedContext ?. run_id === "string" && inheritedContext . run_id . trim ( )
266+ ? inheritedContext . run_id . trim ( )
267+ : currentRunId ;
268+ const assignments = readExperimentAssignments ( ) ;
269+ const experimentAssignments = assignments ? JSON . stringify ( assignments ) : "" ;
150270
151271 return {
152- repo : ` ${ context . repo . owner } / ${ context . repo . repo } ` ,
272+ repo : currentRepo ,
153273 run_id : String ( context . runId ?? "" ) ,
274+ run_attempt : currentRunAttempt ,
154275 // GITHUB_WORKFLOW_REF provides the full workflow file path including the ref,
155276 // e.g. "owner/repo/.github/workflows/dispatcher.yml@refs/heads/main"
156- workflow_id : process . env . GITHUB_WORKFLOW_REF ?? "" ,
157- // workflow_call_id uniquely identifies this specific call attempt:
158- // combine run_id with run_attempt (GITHUB_RUN_ATTEMPT) so re-runs produce different IDs.
159- workflow_call_id : `${ process . env . GITHUB_RUN_ID ?? context . runId ?? "" } -${ process . env . GITHUB_RUN_ATTEMPT ?? "1" } ` ,
277+ workflow_id : workflowRef ,
278+ // episode_id identifies the full automation session across workflow hops.
279+ episode_id : episodeId ,
280+ // hop_id uniquely identifies this specific workflow invocation.
281+ hop_id : currentHopId ,
282+ // parent_hop_id identifies the immediate caller when a workflow was spawned
283+ // by a previous automation hop.
284+ parent_hop_id : parentHopId ,
285+ // origin_event captures the original GitHub event that started the episode.
286+ origin_event : originEvent ,
287+ // root_* fields stay stable across all child workflow hops in the episode.
288+ root_repo : rootRepo ,
289+ root_workflow_id : rootWorkflowId ,
290+ root_run_id : rootRunId ,
291+ // workflow_call_id uniquely identifies this specific workflow invocation,
292+ // including the workflow file when GitHub reuses a single run for caller
293+ // and callee workflow_call executions. Kept as a legacy alias of hop_id.
294+ workflow_call_id : currentHopId ,
160295 time : new Date ( ) . toISOString ( ) ,
161296 actor : context . actor ?? "" ,
162297 event_type : context . eventName ?? "" ,
@@ -184,7 +319,12 @@ function buildAwContext() {
184319 // issues, pull_request, etc.). Empty string for events without label data such as
185320 // workflow_dispatch, push, or schedule.
186321 trigger_label : context . payload ?. label ?. name ?? "" ,
322+ // experiments is a compact JSON string of the A/B experiment variant assignments
323+ // picked by pick_experiment.cjs for the current workflow run (e.g. {"caveman":"yes"}).
324+ // Empty string when no experiments are declared or the assignments file cannot be read.
325+ // Propagated to dispatched child workflows for experiment context continuity.
326+ experiments : experimentAssignments ,
187327 } ;
188328}
189329
190- module . exports = { buildAwContext, resolveItemContext } ;
330+ module . exports = { buildAwContext, buildWorkflowCallId , resolveItemContext } ;
0 commit comments