Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions actions/setup/js/aw_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ function buildWorkflowCallId(runId, runAttempt, workflowRef) {
return normalizedWorkflowRef ? `${baseId}:${normalizedWorkflowRef}` : baseId;
}

/**
* @param {Record<string, unknown> | 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<string, unknown> | 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<string, unknown>}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -437,6 +461,7 @@ function buildAwContext() {
module.exports = {
buildAwContext,
buildWorkflowCallId,
resolveParentHopId,
resolveItemContext,
parseInboundAwContext,
readInboundAwContext,
Expand Down
78 changes: 77 additions & 1 deletion actions/setup/js/aw_context.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions actions/setup/js/scripts/session_schemas.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 24 additions & 7 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" } });
});
});

Expand Down Expand Up @@ -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));
});

Expand Down
12 changes: 12 additions & 0 deletions actions/setup/js/types/unified_session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -288,6 +299,7 @@ export interface WorkflowInfoData {
workflow?: JsonValue;
repository?: JsonValue;
runId?: JsonValue;
episode?: EpisodeLineageData | null;
}

export interface GitHubApiRateLimitData {
Expand Down
36 changes: 35 additions & 1 deletion actions/setup/js/unified_session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
16 changes: 16 additions & 0 deletions actions/setup/js/unified_session_payload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -335,6 +345,7 @@ const EVENT_FIELDS = {
workflow: ["workflow", "workflow_name"],
repository: ["repository"],
runId: ["runId", "run_id"],
episode: ["episode"],
},
"model_routing.outcome": {
status: ["status"],
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading