diff --git a/actions/setup/js/aw_context.cjs b/actions/setup/js/aw_context.cjs index bc2ae28f667..865015b4009 100644 --- a/actions/setup/js/aw_context.cjs +++ b/actions/setup/js/aw_context.cjs @@ -115,6 +115,30 @@ function buildWorkflowCallId(runId, runAttempt, workflowRef) { return normalizedWorkflowRef ? `${baseId}:${normalizedWorkflowRef}` : baseId; } +/** + * @param {Record | null | undefined} awContext + * @returns {string} + */ +function readHopId(awContext) { + const hopId = typeof awContext?.hop_id === "string" ? awContext.hop_id.trim() : ""; + return hopId || (typeof awContext?.workflow_call_id === "string" ? awContext.workflow_call_id.trim() : ""); +} + +/** + * Inbound context describes the caller. Context already describing this hop + * retains its recorded parent instead of creating a self-parent relationship. + * + * @param {Record | null | undefined} awContext + * @param {string} currentHopId + * @returns {string} + */ +function resolveParentHopId(awContext, currentHopId) { + const inheritedHopId = readHopId(awContext); + const recordedParent = typeof awContext?.parent_hop_id === "string" ? awContext.parent_hop_id.trim() : ""; + const parentHopId = inheritedHopId && inheritedHopId !== currentHopId ? inheritedHopId : recordedParent; + return parentHopId !== currentHopId ? parentHopId : ""; +} + /** * @param {unknown} value * @returns {value is Record} @@ -330,8 +354,8 @@ function buildAwContext() { const currentRunAttempt = String(process.env.GITHUB_RUN_ATTEMPT ?? "1"); const currentHopId = buildWorkflowCallId(currentRunId, currentRunAttempt, workflowRef); const inheritedContext = readInboundAwContext(context.payload); - const inheritedHopId = typeof inheritedContext?.hop_id === "string" ? inheritedContext.hop_id.trim() : typeof inheritedContext?.workflow_call_id === "string" ? inheritedContext.workflow_call_id.trim() : ""; - const parentHopId = typeof inheritedContext?.parent_hop_id === "string" && inheritedContext.parent_hop_id.trim() ? inheritedContext.parent_hop_id.trim() : inheritedHopId; + const inheritedHopId = readHopId(inheritedContext); + const parentHopId = resolveParentHopId(inheritedContext, currentHopId); const episodeId = typeof inheritedContext?.episode_id === "string" && inheritedContext.episode_id.trim() ? inheritedContext.episode_id.trim() : inheritedHopId || currentHopId; const originEvent = typeof inheritedContext?.origin_event === "string" && inheritedContext.origin_event.trim() @@ -437,6 +461,7 @@ function buildAwContext() { module.exports = { buildAwContext, buildWorkflowCallId, + resolveParentHopId, resolveItemContext, parseInboundAwContext, readInboundAwContext, diff --git a/actions/setup/js/aw_context.test.cjs b/actions/setup/js/aw_context.test.cjs index 1307090f07b..63ee2d40fbd 100644 --- a/actions/setup/js/aw_context.test.cjs +++ b/actions/setup/js/aw_context.test.cjs @@ -3,7 +3,7 @@ import fs from "fs"; // resolveItemContext does not depend on global context — it only operates on // the plain payload object, so we can test it directly without any mocking. -const { resolveItemContext } = await import("./aw_context.cjs"); +const { resolveItemContext, resolveParentHopId, buildAwContext } = await import("./aw_context.cjs"); const { EXPERIMENT_ASSIGNMENTS_PATH } = await import("./experiment_helpers.cjs"); describe("resolveItemContext", () => { @@ -96,6 +96,82 @@ describe("resolveItemContext", () => { }); }); +describe("resolveParentHopId", () => { + it.each([ + [{ hop_id: "caller", parent_hop_id: "root" }, "child", "caller"], + [{ hop_id: "current", parent_hop_id: "caller" }, "current", "caller"], + [{ hop_id: "current", parent_hop_id: "" }, "current", ""], + [{ hop_id: "current", parent_hop_id: "current" }, "current", ""], + [{ workflow_call_id: "legacy-caller", parent_hop_id: "root" }, "child", "legacy-caller"], + [{ hop_id: " ", workflow_call_id: " legacy-caller " }, "child", "legacy-caller"], + [{ parent_hop_id: " caller " }, "child", "caller"], + [undefined, "root", ""], + ])("resolves the immediate parent for %j", (inbound, current, expected) => { + expect(resolveParentHopId(inbound, current)).toBe(expected); + }); +}); + +describe("buildAwContext episode lineage", () => { + beforeEach(() => { + vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + vi.stubEnv("GITHUB_RUN_ATTEMPT", "1"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + function buildHop(runId, workflow, inbound) { + vi.stubEnv("GITHUB_RUN_ID", String(runId)); + vi.stubEnv("GITHUB_WORKFLOW_REF", `org/repo/.github/workflows/${workflow}.yml@refs/heads/main`); + vi.stubGlobal("context", { + runId, + repo: { owner: "org", repo: "repo" }, + actor: "octocat", + eventName: inbound ? "workflow_dispatch" : "issues", + payload: inbound ? { inputs: { aw_context: JSON.stringify(inbound) } } : {}, + }); + return buildAwContext(); + } + + it("preserves the episode and root while advancing the immediate parent across three hops", () => { + const root = buildHop(100, "root"); + const child = buildHop(200, "child", root); + const grandchild = buildHop(300, "grandchild", child); + expect(root.parent_hop_id).toBe(""); + expect(child.parent_hop_id).toBe(root.hop_id); + expect(grandchild.parent_hop_id).toBe(child.hop_id); + for (const hop of [child, grandchild]) { + expect(hop.episode_id).toBe(root.episode_id); + expect(hop.root_run_id).toBe("100"); + expect(hop.root_repo).toBe(root.root_repo); + expect(hop.root_workflow_id).toBe(root.root_workflow_id); + expect(hop.origin_event).toBe("issues"); + expect(hop.workflow_call_id).toBe(hop.hop_id); + } + }); + + it("distinguishes reusable workflow hops that share a GitHub run", () => { + const root = buildHop(100, "root"); + const child = buildHop(100, "child", root); + const grandchild = buildHop(100, "grandchild", child); + expect(new Set([root.hop_id, child.hop_id, grandchild.hop_id]).size).toBe(3); + expect(child.parent_hop_id).toBe(root.hop_id); + expect(grandchild.parent_hop_id).toBe(child.hop_id); + expect(grandchild.episode_id).toBe(root.episode_id); + }); + + it("retains the recorded parent when context already represents the current hop", () => { + const root = buildHop(100, "root"); + const child = buildHop(200, "child", root); + expect(buildHop(200, "child", child).parent_hop_id).toBe(root.hop_id); + }); +}); + describe("buildAwContext experiments field", () => { let readFileSpy; const savedStateDir = process.env.GH_AW_EXPERIMENT_STATE_DIR; diff --git a/actions/setup/js/scripts/session_schemas.test.cjs b/actions/setup/js/scripts/session_schemas.test.cjs index 1b093e21319..50722b0f665 100644 --- a/actions/setup/js/scripts/session_schemas.test.cjs +++ b/actions/setup/js/scripts/session_schemas.test.cjs @@ -90,6 +90,24 @@ describe("Generated session schemas", () => { expect(validateSession(jsonl([header, unified({ type: "vendor.progress", data: { arbitrary: { body: false } } })]))).toBe(2); }); + it("validates optional episode lineage without accepting arbitrary caller context", () => { + const canonical = { + type: "workflow.info", + data: { + run_id: "300", + context: { episode_id: "100-1:root", hop_id: "200-1:caller", parent_hop_id: "", origin_event: "issues", root_repo: "org/repo", root_workflow_id: "root.yml", root_run_id: "100", private: "omit" }, + }, + }; + const compact = normalizeUnifiedSessionEvent(canonical); + expect(validateSession(jsonl([header, unified(compact)]))).toBe(2); + expect(validateSession(jsonl([header, unified({ type: "workflow.info", data: {} })]))).toBe(2); + expect(validateSession(jsonl([header, unified({ type: "workflow.info", data: { episode: null } })]))).toBe(2); + for (const episode of [{ episode_id: "legacy-key" }, { episodeId: "root", private: "omit" }, "root", []]) { + expect(() => validateSession(jsonl([header, unified({ type: "workflow.info", data: { episode } })]))).toThrow("Invalid session event"); + } + expect(() => validateSession(jsonl([header, unified(canonical)]))).toThrow("Invalid session event"); + }); + it.each(["claude", "copilot", "codex", "gemini", "pi", "custom", "opencode", "goose"])("validates the %s parser's native passthrough and unified collection", engine => { const root = tempRoot(); const input = jsonl([ diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index 38c0115388f..142678a8744 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -4,7 +4,7 @@ const childProcess = require("child_process"); const { randomBytes } = require("crypto"); const fs = require("fs"); -const { buildWorkflowCallId } = require("./aw_context.cjs"); +const { buildWorkflowCallId, resolveParentHopId } = require("./aw_context.cjs"); const path = require("path"); const { nowMs } = require("./performance_now.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); @@ -244,7 +244,7 @@ function buildEpisodeAttributesFromContext(awInfo, runId, runAttempt) { const currentHopId = buildCurrentWorkflowCallId(runId, runAttempt); const inheritedHopId = readContextString(awInfo.context?.hop_id) || readContextString(awInfo.context?.workflow_call_id); const episodeId = readContextString(awInfo.context?.episode_id) || inheritedHopId || currentHopId; - const parentHopId = readContextString(awInfo.context?.parent_hop_id) || (inheritedHopId && inheritedHopId !== currentHopId ? inheritedHopId : ""); + const parentHopId = resolveParentHopId(awInfo.context, currentHopId); const originEvent = readContextString(awInfo.context?.origin_event) || readContextString(awInfo.context?.event_type); const rootRepo = readContextString(awInfo.context?.root_repo) || readContextString(awInfo.context?.repo); const rootWorkflowId = readContextString(awInfo.context?.root_workflow_id) || readContextString(awInfo.context?.workflow_id); diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index 5243212f3f9..a5c27f0cbec 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -246,12 +246,12 @@ describe("buildEpisodeAttributesFromContext", () => { expect(attrs).toContainEqual({ key: "gh-aw.episode.id", value: { stringValue: "episode-42" } }); expect(attrs).toContainEqual({ key: "gh-aw.episode.kind", value: { stringValue: "workflow_call" } }); expect(attrs).toContainEqual({ key: "gh-aw.hop.id", value: { stringValue: "200-3:owner/repo/.github/workflows/child.yml@refs/heads/main" } }); - expect(attrs).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "199-1:owner/repo/.github/workflows/root.yml@refs/heads/main" } }); + expect(attrs).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "200-3:owner/repo/.github/workflows/parent.yml@refs/heads/main" } }); expect(attrs).toContainEqual({ key: "gh-aw.origin.event", value: { stringValue: "workflow_run" } }); expect(attrs).toContainEqual({ key: "gh-aw.root.repo", value: { stringValue: "owner/repo" } }); expect(attrs).toContainEqual({ key: "gh-aw.root.workflow_id", value: { stringValue: "owner/repo/.github/workflows/root.yml@refs/heads/main" } }); expect(attrs).toContainEqual({ key: "gh-aw.workflow_call.id", value: { stringValue: "200-3:owner/repo/.github/workflows/child.yml@refs/heads/main" } }); - expect(attrs).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "199-1:owner/repo/.github/workflows/root.yml@refs/heads/main" } }); + expect(attrs).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "200-3:owner/repo/.github/workflows/parent.yml@refs/heads/main" } }); }); it("falls back to legacy workflow_call_id when canonical lineage fields are absent", () => { @@ -272,6 +272,23 @@ describe("buildEpisodeAttributesFromContext", () => { const keys = attrs.map(attr => attr.key); expect(keys).not.toContain("gh-aw.workflow_call.parent_id"); }); + + it("retains the parent when context already describes the current hop", () => { + vi.stubEnv("GITHUB_WORKFLOW_REF", "owner/repo/.github/workflows/current.yml@refs/heads/main"); + const attrs = buildEpisodeAttributesFromContext({ context: { episode_id: "root-episode", hop_id: "200-1:owner/repo/.github/workflows/current.yml@refs/heads/main", parent_hop_id: "caller" } }, "200", "1"); + expect(attrs).toContainEqual(buildAttr("gh-aw.hop.parent_id", "caller")); + expect(attrs).toContainEqual(buildAttr("gh-aw.workflow_call.parent_id", "caller")); + vi.unstubAllEnvs(); + }); + + it("does not emit a self-parent for root context", () => { + vi.stubEnv("GITHUB_WORKFLOW_REF", "owner/repo/.github/workflows/root.yml@refs/heads/main"); + const hopId = "100-1:owner/repo/.github/workflows/root.yml@refs/heads/main"; + const attrs = buildEpisodeAttributesFromContext({ context: { episode_id: hopId, hop_id: hopId, parent_hop_id: "" } }, "100", "1"); + expect(attrs).toContainEqual(buildAttr("gh-aw.episode.kind", "run")); + expect(attrs.map(attr => attr.key)).not.toContain("gh-aw.hop.parent_id"); + vi.unstubAllEnvs(); + }); }); // --------------------------------------------------------------------------- @@ -1477,7 +1494,7 @@ describe("sendJobSetupSpan", () => { expect(attrs["gh-aw.workflow.name"]).toBe("Smoke Workflow Call"); expect(attrs["gh-aw.episode.id"]).toBe("25280567207-1:owner/repo/.github/workflows/smoke-call-workflow.lock.yml@refs/heads/main"); expect(attrs["gh-aw.hop.id"]).toBe("25280567207-1:owner/repo/.github/workflows/smoke-workflow-call.lock.yml@refs/heads/main"); - expect(attrs["gh-aw.hop.parent_id"]).toBe("parent-hop-root"); + expect(attrs["gh-aw.hop.parent_id"]).toBe("25280567207-1:owner/repo/.github/workflows/smoke-call-workflow.lock.yml@refs/heads/main"); const resourceAttrs = Object.fromEntries(body.resourceSpans[0].resource.attributes.map(a => [a.key, attrValue(a)])); expect(resourceAttrs["github.workflow_ref"]).toBe("owner/repo/.github/workflows/smoke-workflow-call.lock.yml@refs/heads/main"); @@ -2394,11 +2411,11 @@ describe("sendJobSetupSpan", () => { expect(span.attributes).toContainEqual({ key: "gh-aw.episode.id", value: { stringValue: "episode-99" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.episode.kind", value: { stringValue: "workflow_call" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.hop.id", value: { stringValue: "777-3" } }); - expect(span.attributes).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "122-1" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "123-1" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.origin.event", value: { stringValue: "workflow_run" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.root.repo", value: { stringValue: "owner/repo" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.id", value: { stringValue: "777-3" } }); - expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "122-1" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "123-1" } }); }); }); @@ -2736,9 +2753,9 @@ describe("sendJobConclusionSpan", () => { expect(span.attributes).toContainEqual({ key: "gh-aw.episode.id", value: { stringValue: "episode-123" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.episode.kind", value: { stringValue: "workflow_call" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.hop.id", value: { stringValue: "888-4" } }); - expect(span.attributes).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "122-1" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.hop.parent_id", value: { stringValue: "123-1" } }); expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.id", value: { stringValue: "888-4" } }); - expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "122-1" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.workflow_call.parent_id", value: { stringValue: "123-1" } }); expect(span.traceId).toBe("a".repeat(32)); }); diff --git a/actions/setup/js/types/unified_session.d.ts b/actions/setup/js/types/unified_session.d.ts index 0692eed4fc9..f0c50449a5e 100644 --- a/actions/setup/js/types/unified_session.d.ts +++ b/actions/setup/js/types/unified_session.d.ts @@ -274,6 +274,17 @@ export interface DailyAICDecisionData { threshold?: number; } +/** Observed lineage from aw_info.context; hopId identifies the recorded caller. */ +export interface EpisodeLineageData { + episodeId?: JsonValue; + hopId?: JsonValue; + parentHopId?: JsonValue; + originEvent?: JsonValue; + rootRepo?: JsonValue; + rootWorkflowId?: JsonValue; + rootRunId?: JsonValue; +} + export interface WorkflowInfoData { engineId?: JsonValue; agentVersion?: JsonValue; @@ -288,6 +299,7 @@ export interface WorkflowInfoData { workflow?: JsonValue; repository?: JsonValue; runId?: JsonValue; + episode?: EpisodeLineageData | null; } export interface GitHubApiRateLimitData { diff --git a/actions/setup/js/unified_session.test.cjs b/actions/setup/js/unified_session.test.cjs index 4c61628d2ef..226ba8f181b 100644 --- a/actions/setup/js/unified_session.test.cjs +++ b/actions/setup/js/unified_session.test.cjs @@ -437,8 +437,42 @@ describe("Unified conclusion session", () => { expect(events.at(-1).data.absentComponents).not.toContain("workflow"); }); + it.each(["aw_info.json", "activation/aw_info.json", "usage/aw_info.json"])("persists observed episode lineage from trusted %s", metadataPath => { + write(metadataPath, { + engine_id: "copilot", + run_id: "300", + context: { + episode_id: "100-1:root", + hop_id: "200-1:caller", + parent_hop_id: "100-1:root", + origin_event: "issues", + root_repo: "org/repo", + root_workflow_id: "root.yml", + root_run_id: "100", + secret: "PRIVATE_CONTEXT", + }, + }); + write("agent/aw_info.json", { context: { episode_id: "forged", hop_id: "forged" } }); + const events = writeUnifiedSession({ rootDir: root }); + const persisted = fs.readFileSync(path.join(root, "usage/aw_session.jsonl"), "utf8").trimEnd().split("\n").map(JSON.parse); + const workflow = persisted.find(event => event.type === "workflow.info"); + expect(workflow).toMatchObject({ + data: { + runId: "300", + episode: { episodeId: "100-1:root", hopId: "200-1:caller", parentHopId: "100-1:root", originEvent: "issues", rootRepo: "org/repo", rootWorkflowId: "root.yml", rootRunId: "100" }, + }, + provenance: { component: "workflow", path: metadataPath }, + }); + expect(persisted).toEqual(events); + expect(JSON.stringify(workflow)).not.toContain("PRIVATE_CONTEXT"); + expect(JSON.stringify(workflow)).not.toContain("forged"); + const original = fs.readFileSync(path.join(root, "usage/aw_session.jsonl"), "utf8"); + writeUnifiedSession({ rootDir: root }); + expect(fs.readFileSync(path.join(root, "usage/aw_session.jsonl"), "utf8")).toBe(original); + }); + it("does not promote agent-only aw_info metadata to workflow.info", () => { - write("agent/aw_info.json", { engine_id: "claude", model_routing: { status: "selected", wire_model: "forged-model" } }); + write("agent/aw_info.json", { engine_id: "claude", model_routing: { status: "selected", wire_model: "forged-model" }, context: { episode_id: "forged" } }); const { events } = collectUnifiedSession({ rootDir: root, warn: vi.fn() }); expect(events.some(event => event.type === "workflow.info")).toBe(false); }); diff --git a/actions/setup/js/unified_session_payload.cjs b/actions/setup/js/unified_session_payload.cjs index 0f93509657a..2621d04e55c 100644 --- a/actions/setup/js/unified_session_payload.cjs +++ b/actions/setup/js/unified_session_payload.cjs @@ -100,6 +100,16 @@ const MODEL_ROUTING_FIELDS = { failureCode: ["failure_code", "failureCode"], }; /** @type {Fields} */ +const EPISODE_LINEAGE_FIELDS = { + episodeId: ["episodeId", "episode_id"], + hopId: ["hopId", "hop_id"], + parentHopId: ["parentHopId", "parent_hop_id"], + originEvent: ["originEvent", "origin_event"], + rootRepo: ["rootRepo", "root_repo"], + rootWorkflowId: ["rootWorkflowId", "root_workflow_id"], + rootRunId: ["rootRunId", "root_run_id"], +}; +/** @type {Fields} */ const SAFE_OUTPUT_FIELDS = { type: ["type"], repo: ["repo", "repository"], @@ -335,6 +345,7 @@ const EVENT_FIELDS = { workflow: ["workflow", "workflow_name"], repository: ["repository"], runId: ["runId", "run_id"], + episode: ["episode"], }, "model_routing.outcome": { status: ["status"], @@ -405,6 +416,11 @@ function normalizeUnifiedSessionEvent(event, phase) { if (modelRouting && typeof modelRouting === "object" && !Array.isArray(modelRouting)) { data.modelRouting = selectFields(modelRouting, MODEL_ROUTING_FIELDS); } + const episode = Object.hasOwn(source, "episode") ? source.episode : source.context; + if (episode && typeof episode === "object" && !Array.isArray(episode)) { + const lineage = selectFields(episode, EPISODE_LINEAGE_FIELDS); + if (Object.keys(lineage).length || Object.hasOwn(source, "episode")) data.episode = lineage; + } } if (SCOPED_AGENT_TYPES.has(event.type) || (Object.hasOwn(COPILOT_WORKFLOW_EVENT_FIELDS, event.type) && event.type.startsWith("subagent."))) { const parentToolCallId = Object.hasOwn(source, "parentToolCallId") ? source.parentToolCallId : event.parentToolCallId; diff --git a/actions/setup/js/unified_session_payload.test.cjs b/actions/setup/js/unified_session_payload.test.cjs index a5d9b20b9f2..15cb37a93d9 100644 --- a/actions/setup/js/unified_session_payload.test.cjs +++ b/actions/setup/js/unified_session_payload.test.cjs @@ -332,6 +332,61 @@ describe("essential unified session payloads", () => { expect(normalizeUnifiedSessionEvent(metadata)).toEqual(metadata); }); + it("projects only recorded episode lineage from workflow context and round trips canonical fields", () => { + const source = { + type: "workflow.info", + data: { + run_id: "300", + context: { + episode_id: "100-1:root", + hop_id: "200-1:caller", + parent_hop_id: "100-1:root", + origin_event: "issues", + root_repo: "org/repo", + root_workflow_id: "org/repo/.github/workflows/root.yml@refs/heads/main", + root_run_id: "100", + token: "PRIVATE_TOKEN", + otel_trace_id: "PRIVATE_TRACE", + work_queue_assignment: { payload: "PRIVATE_ASSIGNMENT" }, + }, + }, + }; + const original = structuredClone(source); + const compact = normalizeUnifiedSessionEvent(source); + expect(compact.data).toEqual({ + runId: "300", + episode: { + episodeId: "100-1:root", + hopId: "200-1:caller", + parentHopId: "100-1:root", + originEvent: "issues", + rootRepo: "org/repo", + rootWorkflowId: "org/repo/.github/workflows/root.yml@refs/heads/main", + rootRunId: "100", + }, + }); + expect(normalizeUnifiedSessionEvent(compact)).toEqual(compact); + expect(JSON.stringify(compact)).not.toContain("PRIVATE_"); + expect(source).toEqual(original); + }); + + it("preserves explicit canonical episode values and does not infer missing lineage", () => { + const event = { + type: "workflow.info", + data: { + episode: { episodeId: "", episode_id: "wrong", parentHopId: null, parent_hop_id: "wrong", rootRunId: "00100", private: "omit" }, + context: { episode_id: "wrong" }, + }, + }; + const compact = normalizeUnifiedSessionEvent(event); + expect(compact.data).toEqual({ episode: { episodeId: "", parentHopId: null, rootRunId: "00100" } }); + expect(normalizeUnifiedSessionEvent(compact)).toEqual(compact); + expect(normalizeUnifiedSessionEvent({ type: "workflow.info", data: { episode: null, context: { episode_id: "wrong" } } }).data).toEqual({ episode: null }); + for (const context of [undefined, null, {}, [], { workflow_call_id: "legacy", run_id: "100", workflow_id: "caller" }]) { + expect(normalizeUnifiedSessionEvent({ type: "workflow.info", data: { run_id: "200", context } }).data).toEqual({ runId: "200" }); + } + }); + it("uses one accounting shape without losing zero values or adding overlapping totals", () => { const data = { provider: "copilot", diff --git a/actions/setup/js/unified_session_render.cjs b/actions/setup/js/unified_session_render.cjs index 50b98d8e847..764d8dad83a 100644 --- a/actions/setup/js/unified_session_render.cjs +++ b/actions/setup/js/unified_session_render.cjs @@ -175,7 +175,11 @@ function eventDetail(event) { case "detection.result": return fields(data, ["jobResult", "conclusion", "reason", "promptInjection", "secretLeak", "maliciousPatch"]); case "workflow.info": - return fields(data, ["engineId", "model", "requestedModel", "modelRouting", "triggerType", "cliVersion", "awfVersion", "mcpgVersion", "agentVersion", "workflow", "repository", "runId"]); + return ( + fields(data, ["engineId", "model", "requestedModel", "modelRouting", "triggerType", "cliVersion", "awfVersion", "mcpgVersion", "agentVersion", "workflow", "repository", "runId"]) + + " " + + fields(data.episode, ["episodeId", "hopId", "parentHopId", "originEvent", "rootRepo", "rootWorkflowId", "rootRunId"]) + ); case "session.collection_warning": return fields(data, ["path", "line", "code"]); case "session.collection": diff --git a/actions/setup/js/unified_session_render.test.cjs b/actions/setup/js/unified_session_render.test.cjs index 1d149cbab18..a33c290f560 100644 --- a/actions/setup/js/unified_session_render.test.cjs +++ b/actions/setup/js/unified_session_render.test.cjs @@ -97,6 +97,41 @@ describe("unified session publication views", () => { expect(events[3].data.delta).toEqual(delta); }); + it("renders episode lineage in both sinks without dumping the caller context", () => { + const events = [ + header, + event( + "workflow.info", + { + run_id: "300", + context: { + episode_id: "100-1:root", + hop_id: "200-1:caller", + parent_hop_id: "", + origin_event: "issues", + root_repo: "org/repo", + root_workflow_id: "root.yml", + root_run_id: "100", + private: "PRIVATE_CONTEXT", + }, + }, + "workflow", + 0 + ), + ]; + for (const output of [generatePlainTextSummary(events), generateCopilotCliStyleSummary(events)]) { + expect(output).toContain("runId=300"); + expect(output).toContain("episodeId=100-1:root"); + expect(output).toContain("hopId=200-1:caller"); + expect(output).toContain("parentHopId="); + expect(output).toContain("originEvent=issues"); + expect(output).toContain("rootRepo=org/repo"); + expect(output).toContain("rootWorkflowId=root.yml"); + expect(output).toContain("rootRunId=100"); + expect(output).not.toContain("PRIVATE_CONTEXT"); + } + }); + it("renders every known message in both sinks without dumping prompts or opaque payloads", () => { const original = structuredClone(trace); for (const output of [generatePlainTextSummary(trace), generateCopilotCliStyleSummary(trace)]) { diff --git a/docs/public/schemas/unified-session.schema.json b/docs/public/schemas/unified-session.schema.json index e0fbd36d5fe..5aab3b83b29 100644 --- a/docs/public/schemas/unified-session.schema.json +++ b/docs/public/schemas/unified-session.schema.json @@ -454,6 +454,20 @@ }, "additionalProperties": false }, + "EpisodeLineageData": { + "type": "object", + "properties": { + "episodeId": { "$ref": "#/definitions/JsonValue" }, + "hopId": { "$ref": "#/definitions/JsonValue" }, + "parentHopId": { "$ref": "#/definitions/JsonValue" }, + "originEvent": { "$ref": "#/definitions/JsonValue" }, + "rootRepo": { "$ref": "#/definitions/JsonValue" }, + "rootWorkflowId": { "$ref": "#/definitions/JsonValue" }, + "rootRunId": { "$ref": "#/definitions/JsonValue" } + }, + "additionalProperties": false, + "description": "Observed lineage from aw_info.context; hopId identifies the recorded caller." + }, "EvalResultData": { "type": "object", "properties": { "id": { "$ref": "#/definitions/JsonValue" }, "answer": { "$ref": "#/definitions/JsonValue" }, "model": { "$ref": "#/definitions/JsonValue" }, "error": { "$ref": "#/definitions/JsonValue" } }, @@ -804,7 +818,8 @@ "dryRun": { "$ref": "#/definitions/JsonValue" }, "workflow": { "$ref": "#/definitions/JsonValue" }, "repository": { "$ref": "#/definitions/JsonValue" }, - "runId": { "$ref": "#/definitions/JsonValue" } + "runId": { "$ref": "#/definitions/JsonValue" }, + "episode": { "anyOf": [{ "$ref": "#/definitions/EpisodeLineageData" }, { "type": "null" }] } }, "additionalProperties": false } diff --git a/docs/src/content/docs/reference/cost-management.md b/docs/src/content/docs/reference/cost-management.md index b03301624db..27808a58ac3 100644 --- a/docs/src/content/docs/reference/cost-management.md +++ b/docs/src/content/docs/reference/cost-management.md @@ -69,16 +69,20 @@ Each run under `.runs[]` includes `duration`, `token_usage`, `aic`, `workflow_na `gh aw logs --json` emits three views of the same data: `.runs[]` for individual workflow runs, `.episodes[]` for related runs grouped into one logical execution, and `.edges[]` for inferred parent-child lineage. Use `.runs[]` to find which run was resource-heavy and `.episodes[]` to answer "what did this job use end-to-end?". For non-orchestrated workflows, an episode collapses to a single run. +When propagated context includes `episode_id`, the rollup preserves that ID, matching OpenTelemetry correlation, even when intermediate runs are absent. Older context falls back to call IDs and inferred lineage. Totals cover only the runs included in the report; high confidence indicates lineage evidence, not complete episode coverage. Edges connect distinct GitHub run IDs, so reusable workflow hops sharing one run do not produce separate run edges. + +Default JSON omits episode and edge details when every episode contains a single run, while retaining summary counts. Use `--verbose --json` to retain those details. `total_duration` sums execution durations rather than measuring wall-clock time between the first and last hop. + For usage analysis, focus on `total_runs`, `total_tokens`, `total_aic`, `total_duration`, `primary_workflow`, `resource_heavy_node_count`, and `blocked_request_count`. For Claude, Codex, and Copilot runs, `total_aic` is the preferred cost metric because it maps to provider billing in AI Credits (1 AIC = $0.01 USD). ```bash # Top 10 costliest logical executions over the past 30 days by AIC -gh aw logs --start-date -30d --json | \ +gh aw logs --start-date -30d --verbose --json | \ jq '[.episodes[] | {episode: .episode_id, workflow: .primary_workflow, runs: .total_runs, aic: (.total_aic // 0)}] | sort_by(.aic) | reverse | .[:10]' # Top 10 heaviest Copilot executions by AIC -gh aw logs --start-date -30d --engine copilot --json | \ +gh aw logs --start-date -30d --engine copilot --verbose --json | \ jq '[.episodes[] | {episode: .episode_id, workflow: .primary_workflow, runs: .total_runs, aic: (.total_aic // 0)}] | sort_by(.aic) | reverse | .[:10]' ``` diff --git a/docs/src/content/docs/reference/open-telemetry-attributes.mdx b/docs/src/content/docs/reference/open-telemetry-attributes.mdx index 3c93f374642..be736ddbc87 100644 --- a/docs/src/content/docs/reference/open-telemetry-attributes.mdx +++ b/docs/src/content/docs/reference/open-telemetry-attributes.mdx @@ -191,6 +191,8 @@ Routing spans do not include ranked choices, selected IDs, conversation hashes, These attributes are emitted when a run participates in an episode or nested workflow-call structure. +The episode ID stays fixed across hops. Each child records its immediate caller's hop ID as its parent, not the caller's parent. Workflow references distinguish reusable workflow hops that share a GitHub run ID; the logs rollup remains run-level and does not emit edges between hops within that shared run. + @@ -202,9 +204,9 @@ These attributes are emitted when a run participates in an episode or nested wor - + - + diff --git a/docs/src/content/docs/specs/unified-agent-session-specification.md b/docs/src/content/docs/specs/unified-agent-session-specification.md index d5efcc39847..87d0bb67cc9 100644 --- a/docs/src/content/docs/specs/unified-agent-session-specification.md +++ b/docs/src/content/docs/specs/unified-agent-session-specification.md @@ -248,6 +248,7 @@ opaque because its essential fields are not defined by this specification. | Runtime accounting | Provider, model, request ID, status, AIC, cumulative/checkpoint AIC, premium requests, duration, and normalized `usage`; overlapping reports stay separate. Detection and eval usage reports resolve per-observation AIC from known model pricing only when explicit AIC is unavailable; unknown pricing leaves AIC absent, while explicit zero and checkpoints remain authoritative. | | Execution, detection, workflow | Observed outcomes, exit code, duration/start/end, detection job result/conclusion/categorical reason and verdict flags, engine ID, requested model, trigger type, workflow/repository/run ID, and available gh-aw, AWF, MCPG, and agent versions. No detector transcript or free-form reasons. | | Model routing | `firewall.model_routing` retains its stage, purpose, provider, labels, selection, endpoint, router, request, outcome, and deviation fields. `workflow.info` includes the observed `model`, `requestedModel`, and a compact `modelRouting` object. `model_routing.outcome` records the harness's status, wire model, effective and selected endpoints, effort, applied effort, and failure code. | +| Episode lineage | `workflow.info.episode` retains supplied `episodeId`, `hopId`, `parentHopId`, `originEvent`, `rootRepo`, `rootWorkflowId`, and `rootRunId` from runner-owned `aw_info.json` context. No arbitrary caller context, credentials, or work-queue payloads. | Known payload aliases MUST use one canonical key, preferring an explicitly present canonical value even when it is `false`, `0`, `null`, or empty. @@ -267,6 +268,8 @@ comes from the GitHub Actions event name; `cliVersion`, `awfVersion`, are not inferred. The numeric file-format version remains `1`: the `type`/`data`/`provenance` envelope and JSONL framing are unchanged. +The optional `workflow.info.episode` object maps the seven snake_case lineage fields in `aw_info.json.context` to camelCase without inferring absent values or changing identifiers. That context describes the recorded caller: `episode.hopId` is not relabeled as the current run's hop, and `episode.parentHopId` remains that caller's recorded parent. Existing files without lineage remain valid; legacy call IDs alone do not synthesize canonical episode metadata. Only runner-owned workflow metadata supplies this projection, using the collector's existing source precedence. + **T-UAS-055 — Source provenance.** Every merged event MUST have a `provenance` object with `component`, `phase`, `path`, and `index`. `path` MUST be relative to the downloaded runtime root. `index` MUST identify the position in that source's diff --git a/pkg/cli/logs_cached_json_test.go b/pkg/cli/logs_cached_json_test.go index 291d9eb0e9c..c0d9b8096af 100644 --- a/pkg/cli/logs_cached_json_test.go +++ b/pkg/cli/logs_cached_json_test.go @@ -871,6 +871,11 @@ func TestBuildLogsDataPreservesCachedRunRecord(t *testing.T) { LogsPath: "/previous/run-42", Classification: "normal", IntentionalFailure: true, + AwContext: &AwContext{ + Repo: "org/repo", RunID: "41", WorkflowID: "caller", + EpisodeID: "root-episode", HopID: "41-1:caller", ParentHopID: "40-1:root", + RootRepo: "org/repo", RootRunID: "40", RootWorkflowID: "root", OriginEvent: "issues", + }, } outputDir := t.TempDir() @@ -895,6 +900,9 @@ func TestBuildLogsDataPreservesCachedRunRecord(t *testing.T) { assert.Equal(t, 2, data.Summary.TotalChainedFollowupActions) assert.Equal(t, map[string]int{"copilot": 1}, data.Summary.EngineCounts) assert.Equal(t, 1, data.Summary.IntentionalFailureRuns) + require.Len(t, data.Episodes, 1) + assert.Equal(t, cached.AwContext.EpisodeID, data.Episodes[0].EpisodeID) + assert.Equal(t, cached.AwContext, data.Runs[0].AwContext) } func TestHydrateProcessedRunWithCachedAuditLedger(t *testing.T) { diff --git a/pkg/cli/logs_episode.go b/pkg/cli/logs_episode.go index 6df9d3badbb..eeca2f3b4cb 100644 --- a/pkg/cli/logs_episode.go +++ b/pkg/cli/logs_episode.go @@ -9,7 +9,6 @@ import ( "time" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/timeutil" ) var logsEpisodeLog = logger.New("cli:logs_episode") @@ -98,6 +97,7 @@ type episodeSeed struct { Kind string Confidence string Reasons []string + Canonical bool } func buildEpisodeData(runs []RunData, processedRuns []ProcessedRun) ([]EpisodeData, []EpisodeEdge) { @@ -109,22 +109,80 @@ func buildEpisodeData(runs []RunData, processedRuns []ProcessedRun) ([]EpisodeDa for _, run := range runs { runsByID[run.RunID] = run episodeID, kind, confidence, reasons := classifyEpisode(run) - seedsByRunID[run.RunID] = episodeSeed{EpisodeID: episodeID, Kind: kind, Confidence: confidence, Reasons: append([]string(nil), reasons...)} + seedsByRunID[run.RunID] = episodeSeed{ + EpisodeID: episodeID, Kind: kind, Confidence: confidence, + Reasons: append([]string(nil), reasons...), Canonical: canonicalEpisodeID(run) != "", + } parents[run.RunID] = run.RunID } for _, processedRun := range processedRuns { processedByID[processedRun.Run.DatabaseID] = processedRun } + canonicalByRoot := groupCanonicalEpisodes(runs, parents) + edges := buildEpisodeEdges(runs, runsByID, parents, canonicalByRoot) + rootMetadata := selectEpisodeSeeds(runs, parents, seedsByRunID) + episodes := rollupEpisodes(runs, processedByID, parents, rootMetadata) + for index, edge := range edges { + root := findEpisodeParent(parents, edge.TargetRunID) + if selectedSeed, ok := rootMetadata[root]; ok { + edges[index].EpisodeID = selectedSeed.EpisodeID + } + } + slices.SortFunc(episodes, func(a, b EpisodeData) int { + if a.RootRunID != b.RootRunID { + return cmp.Compare(a.RootRunID, b.RootRunID) + } + return cmp.Compare(a.EpisodeID, b.EpisodeID) + }) + slices.SortFunc(edges, func(a, b EpisodeEdge) int { + if a.SourceRunID != b.SourceRunID { + return cmp.Compare(a.SourceRunID, b.SourceRunID) + } + return cmp.Compare(a.TargetRunID, b.TargetRunID) + }) + logsEpisodeLog.Printf("Built %d episodes and %d edges from %d runs", len(episodes), len(edges), len(runs)) + return episodes, edges +} + +func groupCanonicalEpisodes(runs []RunData, parents map[int64]int64) map[int64]string { + canonicalByRoot := make(map[int64]string) + runByEpisodeID := make(map[string]int64) + for _, run := range runs { + if episodeID := canonicalEpisodeID(run); episodeID != "" { + if previous, ok := runByEpisodeID[episodeID]; ok { + unionEpisodes(parents, previous, run.RunID) + } + runByEpisodeID[episodeID] = run.RunID + canonicalByRoot[findEpisodeParent(parents, run.RunID)] = episodeID + } + } + return canonicalByRoot +} + +func buildEpisodeEdges(runs []RunData, runsByID map[int64]RunData, parents map[int64]int64, canonicalByRoot map[int64]string) []EpisodeEdge { edges := make([]EpisodeEdge, 0) for _, run := range runs { if edge, ok := buildEpisodeEdge(run, runs, runsByID); ok { + sourceRoot := findEpisodeParent(parents, edge.SourceRunID) + targetRoot := findEpisodeParent(parents, edge.TargetRunID) + sourceID, targetID := canonicalByRoot[sourceRoot], canonicalByRoot[targetRoot] + if sourceID != "" && targetID != "" && sourceID != targetID { + logsEpisodeLog.Printf("Ignoring conflicting episode edge: source_run=%d target_run=%d", edge.SourceRunID, edge.TargetRunID) + continue + } edges = append(edges, edge) unionEpisodes(parents, edge.SourceRunID, edge.TargetRunID) + if targetID == "" { + targetID = sourceID + } + canonicalByRoot[findEpisodeParent(parents, edge.TargetRunID)] = targetID } } + return edges +} - episodeMap := make(map[string]*episodeAccumulator) +func selectEpisodeSeeds(runs []RunData, parents map[int64]int64, seedsByRunID map[int64]episodeSeed) map[int64]episodeSeed { rootMetadata := make(map[int64]episodeSeed) for _, run := range runs { root := findEpisodeParent(parents, run.RunID) @@ -134,7 +192,11 @@ func buildEpisodeData(runs []RunData, processedRuns []ProcessedRun) ([]EpisodeDa rootMetadata[root] = seed } } + return rootMetadata +} +func rollupEpisodes(runs []RunData, processedByID map[int64]ProcessedRun, parents map[int64]int64, rootMetadata map[int64]episodeSeed) []EpisodeData { + episodeMap := make(map[string]*episodeAccumulator) for _, run := range runs { root := findEpisodeParent(parents, run.RunID) selectedSeed := rootMetadata[root] @@ -158,145 +220,18 @@ func buildEpisodeData(runs []RunData, processedRuns []ProcessedRun) ([]EpisodeDa episodeMap[episodeID] = acc } - if !acc.runSet[run.RunID] { - acc.runSet[run.RunID] = true - acc.metadata.RunIDs = append(acc.metadata.RunIDs, run.RunID) - } - if run.WorkflowName != "" && !acc.nameSet[run.WorkflowName] { - acc.nameSet[run.WorkflowName] = true - acc.metadata.WorkflowNames = append(acc.metadata.WorkflowNames, run.WorkflowName) - } - - acc.metadata.TotalRuns++ - acc.metadata.TotalTokens += run.TokenUsage - acc.metadata.TotalAIC += run.AIC - acc.metadata.ManifestEntryCount += run.ManifestEntryCount - acc.metadata.TemporaryIDMappings += run.TemporaryIDMappings - acc.metadata.ChainedTargetCount += run.ChainedTargetCount - acc.metadata.ChainedFollowupActionCount += run.ChainedFollowupActionCount - acc.metadata.DelegatedTempTargetCount += run.DelegatedTempTargetCount - acc.metadata.ClosedTempTargetCount += run.ClosedTempTargetCount - if run.Comparison != nil && run.Comparison.Classification != nil && run.Comparison.Classification.Label == "risky" { - acc.metadata.RiskyNodeCount++ - } - if run.Comparison != nil && run.Comparison.Classification != nil && run.Comparison.Classification.Label == "changed" { - acc.metadata.ChangedNodeCount++ - } - if run.BehaviorFingerprint != nil && run.BehaviorFingerprint.ActuationStyle != "read_only" { - acc.metadata.WriteCapableNodeCount++ - } - if run.Comparison != nil && run.Comparison.Baseline != nil && run.Comparison.Baseline.Selection == "latest_success" { - acc.metadata.LatestSuccessFallbackCount++ - } - if hasComparisonReasonCode(run.Comparison, "new_mcp_failure") { - acc.metadata.NewMCPFailureRunCount++ - } - if hasComparisonReasonCode(run.Comparison, "blocked_requests_increase") { - acc.metadata.BlockedRequestIncreaseRunCount++ - } - if hasAssessmentKindAtLeast(run.AgenticAssessments, "resource_heavy_for_domain", "medium") { - acc.metadata.ResourceHeavyNodeCount++ - } - if hasAssessmentKindAtLeast(run.AgenticAssessments, "poor_agentic_control", "medium") { - acc.metadata.PoorControlNodeCount++ - } - acc.metadata.MissingToolCount += run.MissingToolCount - if pr, ok := processedByID[run.RunID]; ok { - acc.metadata.MCPFailureCount += len(pr.MCPFailures) - if pr.FirewallAnalysis != nil { - acc.metadata.BlockedRequestCount += pr.FirewallAnalysis.BlockedRequests - if pr.FirewallAnalysis.BlockedRequests >= firewallBlockedRequestCap { - acc.metadata.BlockedRequestAtCap = true - } - } - // Collect per-tool-call metrics for this run. - if pr.MCPToolUsage != nil { - for _, tc := range pr.MCPToolUsage.ToolCalls { - acc.toolCalls = append(acc.toolCalls, mcpToolCallToEpisodeToolCall(tc)) - } - } - } - if !run.CreatedAt.IsZero() && (acc.metadata.RootRunID == 0 || run.CreatedAt.Before(acc.rootTime)) { - acc.rootTime = run.CreatedAt - acc.metadata.RootRunID = run.RunID - acc.metadata.PrimaryWorkflow = run.WorkflowName - } - if acc.metadata.PrimaryWorkflow == "" && run.WorkflowName != "" { - acc.metadata.PrimaryWorkflow = run.WorkflowName - } - if acc.metadata.Repository == "" && run.Repository != "" { - acc.metadata.Repository = run.Repository - if parts := strings.SplitN(run.Repository, "/", 2); len(parts) == 2 { - acc.metadata.Organization = parts[0] - } - } - if run.StartedAt.IsZero() && run.UpdatedAt.IsZero() { - acc.duration += run.CreatedAt.Sub(run.CreatedAt) - } else if !run.StartedAt.IsZero() && !run.UpdatedAt.IsZero() && run.UpdatedAt.After(run.StartedAt) { - acc.duration += run.UpdatedAt.Sub(run.StartedAt) - } else if pr, ok := processedByID[run.RunID]; ok && pr.Run.Duration > 0 { - acc.duration += pr.Run.Duration - } - } - - for index := range edges { - root := findEpisodeParent(parents, edges[index].TargetRunID) - if selectedSeed, ok := rootMetadata[root]; ok { - edges[index].EpisodeID = selectedSeed.EpisodeID - } + acc.addRun(run) + acc.addSignals(run) + acc.addProcessedRun(processedByID[run.RunID]) + acc.addIdentity(run) + acc.addDuration(run, processedByID[run.RunID]) } episodes := make([]EpisodeData, 0, len(episodeMap)) for _, acc := range episodeMap { - slices.Sort(acc.metadata.RunIDs) - slices.Sort(acc.metadata.WorkflowNames) - if acc.duration > 0 { - acc.metadata.TotalDuration = timeutil.FormatDuration(acc.duration) - } - if acc.metadata.PrimaryWorkflow == "" && len(acc.metadata.WorkflowNames) > 0 { - acc.metadata.PrimaryWorkflow = acc.metadata.WorkflowNames[0] - } - switch acc.metadata.RiskyNodeCount { - case 0: - acc.metadata.RiskDistribution = "none" - case 1: - acc.metadata.RiskDistribution = "concentrated" - default: - acc.metadata.RiskDistribution = "distributed" - } - acc.metadata.EscalationEligible, acc.metadata.EscalationReason = classifyEpisodeEscalation(acc.metadata) - acc.metadata.SuggestedRoute = buildSuggestedRoute(acc.metadata) - if len(acc.toolCalls) > 0 { - // Sort tool calls for deterministic output (server, then tool name, then status). - slices.SortFunc(acc.toolCalls, func(a, b EpisodeToolCall) int { - if a.Server != b.Server { - return cmp.Compare(a.Server, b.Server) - } - if a.Tool != b.Tool { - return cmp.Compare(a.Tool, b.Tool) - } - return cmp.Compare(a.Status, b.Status) - }) - acc.metadata.ToolCalls = acc.toolCalls - } - episodes = append(episodes, acc.metadata) + episodes = append(episodes, acc.finalize()) } - - slices.SortFunc(episodes, func(a, b EpisodeData) int { - if a.RootRunID != b.RootRunID { - return cmp.Compare(a.RootRunID, b.RootRunID) - } - return cmp.Compare(a.EpisodeID, b.EpisodeID) - }) - slices.SortFunc(edges, func(a, b EpisodeEdge) int { - if a.SourceRunID != b.SourceRunID { - return cmp.Compare(a.SourceRunID, b.SourceRunID) - } - return cmp.Compare(a.TargetRunID, b.TargetRunID) - }) - - logsEpisodeLog.Printf("Built %d episodes and %d edges from %d runs", len(episodes), len(edges), len(runs)) - return episodes, edges + return episodes } // mcpToolCallToEpisodeToolCall converts an MCPToolCall record to the lightweight @@ -339,6 +274,12 @@ func unionEpisodes(parents map[int64]int64, leftRunID, rightRunID int64) { } func compareEpisodeSeeds(left, right episodeSeed) int { + if left.Canonical != right.Canonical { + if left.Canonical { + return 1 + } + return -1 + } if left.Kind != right.Kind { return cmp.Compare(seedKindRank(left.Kind), seedKindRank(right.Kind)) } @@ -374,6 +315,15 @@ func seedConfidenceRank(confidence string) int { func classifyEpisode(run RunData) (string, string, string, []string) { logsEpisodeLog.Printf("Classifying episode for run: id=%d event=%s", run.RunID, run.Event) + if episodeID := canonicalEpisodeID(run); episodeID != "" { + kind := "dispatch_workflow" + if run.Event == "workflow_call" { + kind = "workflow_call" + } else if run.AwContext.RunID == strconv.FormatInt(run.RunID, 10) && run.AwContext.ParentHopID == "" { + kind = "standalone" + } + return episodeID, kind, "high", []string{"context.episode_id"} + } if run.AwContext != nil { if run.AwContext.WorkflowCallID != "" { return "dispatch:" + run.AwContext.WorkflowCallID, "dispatch_workflow", "high", []string{"context.workflow_call_id"} @@ -391,10 +341,22 @@ func classifyEpisode(run RunData) (string, string, string, []string) { return fmt.Sprintf("standalone:%d", run.RunID), "standalone", "high", []string{"no_shared_lineage_markers"} } +func canonicalEpisodeID(run RunData) string { + if run.AwContext == nil { + return "" + } + return strings.TrimSpace(run.AwContext.EpisodeID) +} + func buildEpisodeEdge(run RunData, runs []RunData, runsByID map[int64]RunData) (EpisodeEdge, bool) { if edge, ok := buildDispatchEpisodeEdge(run, runsByID); ok { return edge, true } + // Canonical context is authoritative; missing or same-run callers are not + // evidence for an inferred edge to a different run. + if canonicalEpisodeID(run) != "" || (run.AwContext != nil && run.AwContext.RunID == strconv.FormatInt(run.RunID, 10)) { + return EpisodeEdge{}, false + } if edge, ok := buildWorkflowCallEpisodeEdge(run, runs); ok { return edge, true } @@ -413,10 +375,18 @@ func buildDispatchEpisodeEdge(run RunData, runsByID map[int64]RunData) (EpisodeE logsEpisodeLog.Printf("Failed to parse dispatch source run ID for run %d: %v", run.RunID, err) return EpisodeEdge{}, false } - if _, ok := runsByID[sourceRunID]; !ok { + source, ok := runsByID[sourceRunID] + if !ok { logsEpisodeLog.Printf("Dispatch source run %d not found in run set for run %d", sourceRunID, run.RunID) return EpisodeEdge{}, false } + if sourceRunID == run.RunID { + return EpisodeEdge{}, false + } + if run.AwContext.Repo != "" && source.Repository != "" && run.AwContext.Repo != source.Repository { + logsEpisodeLog.Printf("Ignoring dispatch source repository mismatch: source_run=%d target_run=%d", sourceRunID, run.RunID) + return EpisodeEdge{}, false + } logsEpisodeLog.Printf("Building dispatch episode edge: target_run=%d source_run=%d", run.RunID, sourceRunID) confidence := "medium" reasons := []string{"context.run_id"} @@ -427,10 +397,18 @@ func buildDispatchEpisodeEdge(run RunData, runsByID map[int64]RunData) (EpisodeE if run.AwContext.WorkflowID != "" { reasons = append(reasons, "context.workflow_id") } + edgeType := "dispatch_workflow" + if canonicalEpisodeID(run) != "" { + confidence = "high" + reasons = append(reasons, "context.episode_id") + if run.Event == "workflow_call" { + edgeType = "workflow_call" + } + } return EpisodeEdge{ SourceRunID: sourceRunID, TargetRunID: run.RunID, - EdgeType: "dispatch_workflow", + EdgeType: edgeType, Confidence: confidence, Reasons: reasons, SourceRepo: run.AwContext.Repo, @@ -556,17 +534,19 @@ func buildUniqueCandidateEdge(run RunData, candidates []RunData, edgeType, confi if len(candidates) != 1 { return EpisodeEdge{}, false } - parent := candidates[0] - return EpisodeEdge{ - SourceRunID: parent.RunID, - TargetRunID: run.RunID, - EdgeType: edgeType, - Confidence: confidence, - Reasons: append([]string(nil), reasons...), - SourceRepo: parent.Repository, - SourceRef: parent.WorkflowPath, - EventType: parent.Event, - }, true + for _, parent := range candidates { + return EpisodeEdge{ + SourceRunID: parent.RunID, + TargetRunID: run.RunID, + EdgeType: edgeType, + Confidence: confidence, + Reasons: append([]string(nil), reasons...), + SourceRepo: parent.Repository, + SourceRef: parent.WorkflowPath, + EventType: parent.Event, + }, true + } + return EpisodeEdge{}, false } func hasComparisonReasonCode(comparison *AuditComparisonData, code string) bool { diff --git a/pkg/cli/logs_episode_rollup.go b/pkg/cli/logs_episode_rollup.go new file mode 100644 index 00000000000..a82f12a1f6d --- /dev/null +++ b/pkg/cli/logs_episode_rollup.go @@ -0,0 +1,134 @@ +package cli + +import ( + "cmp" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/timeutil" +) + +func (acc *episodeAccumulator) addRun(run RunData) { + if !acc.runSet[run.RunID] { + acc.runSet[run.RunID] = true + acc.metadata.RunIDs = append(acc.metadata.RunIDs, run.RunID) + } + if run.WorkflowName != "" && !acc.nameSet[run.WorkflowName] { + acc.nameSet[run.WorkflowName] = true + acc.metadata.WorkflowNames = append(acc.metadata.WorkflowNames, run.WorkflowName) + } + acc.metadata.TotalRuns++ + acc.metadata.TotalTokens += run.TokenUsage + acc.metadata.TotalAIC += run.AIC + acc.metadata.ManifestEntryCount += run.ManifestEntryCount + acc.metadata.TemporaryIDMappings += run.TemporaryIDMappings + acc.metadata.ChainedTargetCount += run.ChainedTargetCount + acc.metadata.ChainedFollowupActionCount += run.ChainedFollowupActionCount + acc.metadata.DelegatedTempTargetCount += run.DelegatedTempTargetCount + acc.metadata.ClosedTempTargetCount += run.ClosedTempTargetCount + acc.metadata.MissingToolCount += run.MissingToolCount +} + +func (acc *episodeAccumulator) addSignals(run RunData) { + if run.Comparison != nil && run.Comparison.Classification != nil && run.Comparison.Classification.Label == "risky" { + acc.metadata.RiskyNodeCount++ + } + if run.Comparison != nil && run.Comparison.Classification != nil && run.Comparison.Classification.Label == "changed" { + acc.metadata.ChangedNodeCount++ + } + if run.BehaviorFingerprint != nil && run.BehaviorFingerprint.ActuationStyle != "read_only" { + acc.metadata.WriteCapableNodeCount++ + } + if run.Comparison != nil && run.Comparison.Baseline != nil && run.Comparison.Baseline.Selection == "latest_success" { + acc.metadata.LatestSuccessFallbackCount++ + } + if hasComparisonReasonCode(run.Comparison, "new_mcp_failure") { + acc.metadata.NewMCPFailureRunCount++ + } + if hasComparisonReasonCode(run.Comparison, "blocked_requests_increase") { + acc.metadata.BlockedRequestIncreaseRunCount++ + } + if hasAssessmentKindAtLeast(run.AgenticAssessments, "resource_heavy_for_domain", "medium") { + acc.metadata.ResourceHeavyNodeCount++ + } + if hasAssessmentKindAtLeast(run.AgenticAssessments, "poor_agentic_control", "medium") { + acc.metadata.PoorControlNodeCount++ + } +} + +func (acc *episodeAccumulator) addProcessedRun(run ProcessedRun) { + acc.metadata.MCPFailureCount += len(run.MCPFailures) + if run.FirewallAnalysis != nil { + acc.metadata.BlockedRequestCount += run.FirewallAnalysis.BlockedRequests + if run.FirewallAnalysis.BlockedRequests >= firewallBlockedRequestCap { + acc.metadata.BlockedRequestAtCap = true + } + } + if run.MCPToolUsage != nil { + for _, call := range run.MCPToolUsage.ToolCalls { + acc.toolCalls = append(acc.toolCalls, mcpToolCallToEpisodeToolCall(call)) + } + } +} + +func (acc *episodeAccumulator) addIdentity(run RunData) { + if !run.CreatedAt.IsZero() && (acc.metadata.RootRunID == 0 || run.CreatedAt.Before(acc.rootTime)) { + acc.rootTime = run.CreatedAt + acc.metadata.RootRunID = run.RunID + acc.metadata.PrimaryWorkflow = run.WorkflowName + } + if acc.metadata.PrimaryWorkflow == "" && run.WorkflowName != "" { + acc.metadata.PrimaryWorkflow = run.WorkflowName + } + if acc.metadata.Repository == "" && run.Repository != "" { + acc.metadata.Repository = run.Repository + if organization, _, found := strings.Cut(run.Repository, "/"); found { + acc.metadata.Organization = organization + } + } +} + +func (acc *episodeAccumulator) addDuration(run RunData, processedRun ProcessedRun) { + if run.StartedAt.IsZero() && run.UpdatedAt.IsZero() { + return + } + if !run.StartedAt.IsZero() && !run.UpdatedAt.IsZero() && run.UpdatedAt.After(run.StartedAt) { + acc.duration += run.UpdatedAt.Sub(run.StartedAt) + } else if processedRun.Run.Duration > 0 { + acc.duration += processedRun.Run.Duration + } +} + +func (acc *episodeAccumulator) finalize() EpisodeData { + slices.Sort(acc.metadata.RunIDs) + slices.Sort(acc.metadata.WorkflowNames) + if acc.duration > 0 { + acc.metadata.TotalDuration = timeutil.FormatDuration(acc.duration) + } + if acc.metadata.PrimaryWorkflow == "" && len(acc.metadata.WorkflowNames) > 0 { + acc.metadata.PrimaryWorkflow = acc.metadata.WorkflowNames[0] + } + switch acc.metadata.RiskyNodeCount { + case 0: + acc.metadata.RiskDistribution = "none" + case 1: + acc.metadata.RiskDistribution = "concentrated" + default: + acc.metadata.RiskDistribution = "distributed" + } + acc.metadata.EscalationEligible, acc.metadata.EscalationReason = classifyEpisodeEscalation(acc.metadata) + acc.metadata.SuggestedRoute = buildSuggestedRoute(acc.metadata) + if len(acc.toolCalls) > 0 { + slices.SortFunc(acc.toolCalls, func(a, b EpisodeToolCall) int { + if a.Server != b.Server { + return cmp.Compare(a.Server, b.Server) + } + if a.Tool != b.Tool { + return cmp.Compare(a.Tool, b.Tool) + } + return cmp.Compare(a.Status, b.Status) + }) + acc.metadata.ToolCalls = acc.toolCalls + } + return acc.metadata +} diff --git a/pkg/cli/logs_episode_test.go b/pkg/cli/logs_episode_test.go index b993688a91d..66592b42d59 100644 --- a/pkg/cli/logs_episode_test.go +++ b/pkg/cli/logs_episode_test.go @@ -212,6 +212,123 @@ func TestBuildEpisodeDataAggregatesAIC(t *testing.T) { assert.InDelta(t, 0.345, byRunID[502].TotalAIC, 1e-9, "episode should preserve AIC from run 502") } +func TestBuildEpisodeDataPreservesCanonicalIdentityAcrossSelections(t *testing.T) { + t.Parallel() + + const episodeID = "100-1:org/repo/.github/workflows/root.yml@refs/heads/main" + root := RunData{ + RunID: 100, Repository: "org/repo", TokenUsage: 10, AIC: 0.1, + AwContext: &AwContext{RunID: "100", EpisodeID: episodeID}, + } + legacyRoot := root + legacyRoot.AwContext = nil + child := RunData{ + RunID: 200, Repository: "org/repo", TokenUsage: 20, AIC: 0.2, + AwContext: &AwContext{Repo: "org/repo", RunID: "100", WorkflowCallID: episodeID, EpisodeID: episodeID}, + } + grandchild := RunData{ + RunID: 300, Repository: "org/repo", TokenUsage: 30, AIC: 0.3, + AwContext: &AwContext{Repo: "org/repo", RunID: "200", WorkflowCallID: "200-1:worker", EpisodeID: episodeID}, + } + + tests := []struct { + name string + runs []RunData + edgeCount int + tokens int + aic float64 + }{ + {"full chain", []RunData{root, child, grandchild}, 2, 60, 0.6}, + {"legacy root", []RunData{legacyRoot, child, grandchild}, 2, 60, 0.6}, + {"reversed chain", []RunData{grandchild, child, root}, 2, 60, 0.6}, + {"missing root", []RunData{child, grandchild}, 1, 50, 0.5}, + {"missing middle", []RunData{root, grandchild}, 0, 40, 0.4}, + {"single descendant", []RunData{grandchild}, 0, 30, 0.3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + episodes, edges := buildEpisodeData(tt.runs, nil) + require.Len(t, episodes, 1) + assert.Equal(t, episodeID, episodes[0].EpisodeID) + assert.Equal(t, len(tt.runs), episodes[0].TotalRuns) + assert.Equal(t, tt.tokens, episodes[0].TotalTokens) + assert.InDelta(t, tt.aic, episodes[0].TotalAIC, 1e-9) + require.Len(t, edges, tt.edgeCount) + for _, edge := range edges { + assert.Equal(t, episodeID, edge.EpisodeID) + assert.NotEqual(t, edge.SourceRunID, edge.TargetRunID) + } + }) + } +} + +func TestBuildEpisodeDataRejectsConflictingCanonicalLineage(t *testing.T) { + t.Parallel() + + for _, runs := range [][]RunData{ + { + {RunID: 100, AwContext: &AwContext{EpisodeID: "episode-a"}}, + {RunID: 200, AwContext: &AwContext{RunID: "100", EpisodeID: "episode-b"}}, + }, + { + {RunID: 100, AwContext: &AwContext{EpisodeID: "episode-a"}}, + {RunID: 150, AwContext: &AwContext{RunID: "100"}}, + {RunID: 200, AwContext: &AwContext{RunID: "150", EpisodeID: "episode-b"}}, + }, + } { + episodes, edges := buildEpisodeData(runs, nil) + require.Len(t, episodes, 2) + for _, edge := range edges { + assert.NotEqual(t, int64(200), edge.TargetRunID, "a legacy bridge must not merge different canonical episodes") + } + ids := []string{episodes[0].EpisodeID, episodes[1].EpisodeID} + assert.ElementsMatch(t, []string{"episode-a", "episode-b"}, ids) + } +} + +func TestBuildEpisodeDataCanonicalContextDoesNotInferMissingCaller(t *testing.T) { + t.Parallel() + + created := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + runs := []RunData{ + {RunID: 100, Event: "push", Repository: "org/repo", HeadSHA: "abc", Branch: "main", CreatedAt: created}, + { + RunID: 200, Event: "workflow_run", Repository: "org/repo", HeadSHA: "abc", Branch: "main", CreatedAt: created.Add(time.Minute), + AwContext: &AwContext{RunID: "999", EpisodeID: "canonical-episode"}, + }, + } + episodes, edges := buildEpisodeData(runs, nil) + assert.Len(t, episodes, 2) + assert.Empty(t, edges, "a matching SHA is not a substitute for the absent canonical caller") +} + +func TestBuildEpisodeDataSharedRunDoesNotCreateSelfEdge(t *testing.T) { + t.Parallel() + + run := RunData{ + RunID: 100, Event: "workflow_call", TokenUsage: 20, + AwContext: &AwContext{ + RunID: "100", EpisodeID: "root-episode", + HopID: "100-1:caller", ParentHopID: "100-1:root", + }, + } + episodes, edges := buildEpisodeData([]RunData{run}, nil) + require.Len(t, episodes, 1) + assert.Equal(t, "root-episode", episodes[0].EpisodeID) + assert.Equal(t, 1, episodes[0].TotalRuns) + assert.Equal(t, 20, episodes[0].TotalTokens) + assert.Empty(t, edges, "hop relationships within a shared run are not run-to-run edges") +} + +func TestBuildDispatchEpisodeEdgeRejectsRepositoryMismatch(t *testing.T) { + t.Parallel() + + run := RunData{RunID: 200, AwContext: &AwContext{RunID: "100", Repo: "other/repo"}} + _, ok := buildDispatchEpisodeEdge(run, map[int64]RunData{100: {RunID: 100, Repository: "org/repo"}}) + assert.False(t, ok) +} + func TestBuildEpisodeDataAggregatesToolCallsAcrossRuns(t *testing.T) { t.Parallel() diff --git a/pkg/cli/logs_json_test.go b/pkg/cli/logs_json_test.go index 074383493f9..9e297744dbc 100644 --- a/pkg/cli/logs_json_test.go +++ b/pkg/cli/logs_json_test.go @@ -310,6 +310,30 @@ func writeTestAwInfo(t *testing.T, runDir string, payload map[string]any) { } } +func TestBuildLogsDataPreservesCanonicalContextFromAwInfo(t *testing.T) { + t.Parallel() + + runDir := filepath.Join(t.TempDir(), "run-200") + expected := &AwContext{ + Repo: "org/repo", RunID: "100", WorkflowID: "org/repo/.github/workflows/caller.yml@refs/heads/main", + WorkflowCallID: "100-1:caller", EpisodeID: "root-episode", HopID: "100-1:caller", ParentHopID: "50-1:root", + OriginEvent: "issues", RootRepo: "org/repo", RootWorkflowID: "org/repo/.github/workflows/root.yml@refs/heads/main", RootRunID: "50", + } + writeTestAwInfo(t, runDir, map[string]any{"engine_id": "copilot", "context": expected}) + data := buildLogsData([]ProcessedRun{{Run: WorkflowRun{DatabaseID: 200, LogsPath: runDir}}}, runDir, nil) + require.Len(t, data.Runs, 1) + assert.Equal(t, expected, data.Runs[0].AwContext) + require.Len(t, data.Episodes, 1) + assert.Equal(t, expected.EpisodeID, data.Episodes[0].EpisodeID) + + raw, err := json.Marshal(data) + require.NoError(t, err) + var decoded LogsData + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, expected, decoded.Runs[0].AwContext) + assert.Equal(t, expected.EpisodeID, decoded.Episodes[0].EpisodeID) +} + func TestBuildLogsDataAggregatesDispatchEpisode(t *testing.T) { t.Parallel() tmpDir := testutil.TempDir(t, "test-episode-*") diff --git a/pkg/cli/logs_models.go b/pkg/cli/logs_models.go index e5078ca9083..e63ad807fce 100644 --- a/pkg/cli/logs_models.go +++ b/pkg/cli/logs_models.go @@ -426,12 +426,19 @@ type AwContext struct { RunID string `json:"run_id"` // GitHub Actions run ID of the calling workflow WorkflowID string `json:"workflow_id"` // Full workflow ref, e.g. "owner/repo/.github/workflows/foo.yml@refs/heads/main" WorkflowCallID string `json:"workflow_call_id,omitempty"` // Unique call attempt ID (run_id + run_attempt) - Time string `json:"time,omitempty"` // ISO 8601 timestamp of the dispatch - Actor string `json:"actor,omitempty"` // GitHub actor that triggered the calling workflow - EventType string `json:"event_type,omitempty"` // GitHub event name of the calling workflow - ItemType string `json:"item_type,omitempty"` // Kind of triggering item: "issue", "pull_request", "discussion", "check_run", "check_suite", or "" - ItemNumber string `json:"item_number,omitempty"` // Number (issue/PR/discussion) or database id (check_run/check_suite) of the triggering item - CommentID string `json:"comment_id,omitempty"` // ID of the triggering comment or review; empty when not a comment/review event + EpisodeID string `json:"episode_id,omitempty"` + HopID string `json:"hop_id,omitempty"` + ParentHopID string `json:"parent_hop_id,omitempty"` + OriginEvent string `json:"origin_event,omitempty"` + RootRepo string `json:"root_repo,omitempty"` + RootWorkflowID string `json:"root_workflow_id,omitempty"` + RootRunID string `json:"root_run_id,omitempty"` + Time string `json:"time,omitempty"` // ISO 8601 timestamp of the dispatch + Actor string `json:"actor,omitempty"` // GitHub actor that triggered the calling workflow + EventType string `json:"event_type,omitempty"` // GitHub event name of the calling workflow + ItemType string `json:"item_type,omitempty"` // Kind of triggering item: "issue", "pull_request", "discussion", "check_run", "check_suite", or "" + ItemNumber string `json:"item_number,omitempty"` // Number (issue/PR/discussion) or database id (check_run/check_suite) of the triggering item + CommentID string `json:"comment_id,omitempty"` // ID of the triggering comment or review; empty when not a comment/review event } // AwInfo represents the structure of aw_info.json files diff --git a/schemas/audit.schema.json b/schemas/audit.schema.json index 53575b42ca8..8eb3b93ae18 100644 --- a/schemas/audit.schema.json +++ b/schemas/audit.schema.json @@ -69,6 +69,27 @@ "workflow_call_id": { "type": "string" }, + "episode_id": { + "type": "string" + }, + "hop_id": { + "type": "string" + }, + "parent_hop_id": { + "type": "string" + }, + "origin_event": { + "type": "string" + }, + "root_repo": { + "type": "string" + }, + "root_workflow_id": { + "type": "string" + }, + "root_run_id": { + "type": "string" + }, "time": { "type": "string" }, diff --git a/schemas/logs-jsonl.schema.json b/schemas/logs-jsonl.schema.json index 86bb130bc7c..05d5f6eb076 100644 --- a/schemas/logs-jsonl.schema.json +++ b/schemas/logs-jsonl.schema.json @@ -1882,6 +1882,27 @@ "workflow_call_id": { "type": "string" }, + "episode_id": { + "type": "string" + }, + "hop_id": { + "type": "string" + }, + "parent_hop_id": { + "type": "string" + }, + "origin_event": { + "type": "string" + }, + "root_repo": { + "type": "string" + }, + "root_workflow_id": { + "type": "string" + }, + "root_run_id": { + "type": "string" + }, "time": { "type": "string" }, @@ -3185,6 +3206,27 @@ "workflow_call_id": { "type": "string" }, + "episode_id": { + "type": "string" + }, + "hop_id": { + "type": "string" + }, + "parent_hop_id": { + "type": "string" + }, + "origin_event": { + "type": "string" + }, + "root_repo": { + "type": "string" + }, + "root_workflow_id": { + "type": "string" + }, + "root_run_id": { + "type": "string" + }, "time": { "type": "string" }, @@ -7895,6 +7937,27 @@ "workflow_call_id": { "type": "string" }, + "episode_id": { + "type": "string" + }, + "hop_id": { + "type": "string" + }, + "parent_hop_id": { + "type": "string" + }, + "origin_event": { + "type": "string" + }, + "root_repo": { + "type": "string" + }, + "root_workflow_id": { + "type": "string" + }, + "root_run_id": { + "type": "string" + }, "time": { "type": "string" }, diff --git a/schemas/logs.schema.json b/schemas/logs.schema.json index 3979477a4e4..c5cb9698ee8 100644 --- a/schemas/logs.schema.json +++ b/schemas/logs.schema.json @@ -2004,6 +2004,27 @@ "workflow_call_id": { "type": "string" }, + "episode_id": { + "type": "string" + }, + "hop_id": { + "type": "string" + }, + "parent_hop_id": { + "type": "string" + }, + "origin_event": { + "type": "string" + }, + "root_repo": { + "type": "string" + }, + "root_workflow_id": { + "type": "string" + }, + "root_run_id": { + "type": "string" + }, "time": { "type": "string" },
gh-aw.episode.idEpisode identifier used to roll up related runs.
gh-aw.episode.kindEpisode kind such as run or workflow_call.
gh-aw.hop.idCurrent hop identifier within an episode.
gh-aw.hop.parent_idParent hop identifier when nested.
gh-aw.hop.parent_idImmediate caller's hop identifier when nested; omitted for root hops.
gh-aw.workflow_call.idCurrent workflow-call hop identifier.
gh-aw.workflow_call.parent_idParent workflow-call hop identifier.
gh-aw.workflow_call.parent_idLegacy alias of gh-aw.hop.parent_id.
gh-aw.origin.eventOriginal triggering event for the root execution.
gh-aw.root.repoRoot repository for the episode.
gh-aw.root.workflow_idRoot workflow identifier.