Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
71 changes: 71 additions & 0 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,20 @@ const OTLP_EXPORT_ERROR_DETAILS_PATH = "/tmp/gh-aw/otlp-export-errors.jsonl";
*/
const FAILURE_CATEGORIES_PATH = "/tmp/gh-aw/failure_categories.json";

/**
* Path to the per-agent AI usage JSONL file written to the temp folder.
* Read by the conclusion job post-step before /tmp/gh-aw/ is deleted.
* @type {string}
*/
const AGENTS_USAGE_JSONL_PATH = "/tmp/gh-aw/agent_usage.jsonl";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Naming inconsistency: AGENTS_USAGE_JSONL_PATH uses the plural AGENTS_ but the actual filename is agent_usage.jsonl (singular), the Go constant is AgentUsageJsonlFilename (singular), and the JSDoc comment above says "per-agent" (singular). Should be AGENT_USAGE_JSONL_PATH to match.

Details

All three sources of truth agree on the singular form:

  • Filename: agent_usage.jsonl
  • Go constant: AgentUsageJsonlFilename
  • JSDoc: "per-agent AI usage JSONL file"

Only the JS constant name breaks the pattern. Renaming to AGENT_USAGE_JSONL_PATH keeps cross-language naming aligned and avoids confusion when comparing JS and Go constants.


Comment on lines +1440 to +1453
/**
* Path to the detection job AI usage JSONL file written to the temp folder.
* Read by the conclusion job post-step before /tmp/gh-aw/ is deleted.
* @type {string}
*/
const DETECTION_USAGE_JSONL_PATH = "/tmp/gh-aw/detection_usage.jsonl";

/**
* Path to the agent stdio log file.
* @type {string}
Expand Down Expand Up @@ -1705,6 +1719,45 @@ function normalizeRuntimeTokenUsage(rawUsage) {
return Object.keys(normalized).length > 0 ? normalized : undefined;
}

/**
* Parse a JSONL usage file and return the total AI credits consumed.
*
* Each line is expected to be a JSON object containing an `ai_credits` or
* `aiCredits` field. Lines that are missing, malformed, or lack a valid
* non-negative numeric AI-credits field are silently skipped.
*
* Used by the conclusion job post-step to read AI usage data from
* `/tmp/gh-aw/agent_usage.jsonl` and `/tmp/gh-aw/detection_usage.jsonl`
* before the temp folder is deleted.
*
* @param {string} filePath - Absolute path to the JSONL usage file
* @returns {number} Total AI credits summed across all valid entries (0 when the
* file is absent, empty, or contains no entries with a valid AI-credits field)
*/
function parseAICreditsFromUsageJsonl(filePath) {
try {
const content = fs.readFileSync(filePath, "utf8");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unbounded readFileSync with no size guard — a runaway writer can OOM the post-step: fs.readFileSync loads the entire file into memory. A buggy or misbehaving agent that appends to agent_usage.jsonl without bound will cause this post-step read to exhaust heap and crash, silently losing all telemetry for the run.

💡 Suggested fix

Add a stat-based size check before reading:

function parseAICreditsFromUsageJsonl(filePath) {
  try {
    const MAX_BYTES = 10 * 1024 * 1024; // 10 MB sanity cap
    const stat = fs.statSync(filePath);
    if (stat.size > MAX_BYTES) return 0;
    const content = fs.readFileSync(filePath, "utf8");
    // ... rest unchanged
  } catch {
    return 0;
  }
}

statSync is already in a try/catch so ENOENT on missing files is still handled transparently. Files over the cap are skipped (returns 0) rather than crashing the runner.

if (!content.trim()) return 0;
let total = 0;
for (const line of content.split("\n")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot reuse helper to parse JSONL files that already exist

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in 44546c3d87: parseAICreditsFromUsageJsonl now reuses the existing parseJsonlContent helper for JSONL parsing, while preserving the empty-file fast return behavior.

const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (!entry || typeof entry !== "object") continue;
const raw = "ai_credits" in entry ? entry.ai_credits : "aiCredits" in entry ? entry.aiCredits : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent fallback suppressed when ai_credits is present but invalid: The ternary "ai_credits" in entry ? entry.ai_credits : "aiCredits" in entry ? entry.aiCredits : undefined uses in-check for branch selection, not value validity. If an entry has { ai_credits: null, aiCredits: 1.5 }, raw becomes null, normalizeNonNegativeNumber(null) returns undefined, and the valid aiCredits: 1.5 is silently dropped.

💡 Suggested fix

Apply the fallback chain after normalization, not before:

const raw =
  normalizeNonNegativeNumber(entry.ai_credits) ??
  normalizeNonNegativeNumber(entry.aiCredits);
if (typeof raw === "number") total += raw;

This preserves the existing preference for ai_credits (it is tried first) while correctly falling through to aiCredits when ai_credits is present but invalid. The existing test "prefers snake_case ai_credits over camelCase aiCredits" still passes because ai_credits: 1.0 normalizes to 1.0 and short-circuits ??.

const parsed = normalizeNonNegativeNumber(raw);
if (typeof parsed === "number") total += parsed;
} catch {
// ignore malformed lines
}
}
return total;
} catch {
return 0;
}
}

/**
* Read steering-event count from the first available API proxy event-log JSONL.
*
Expand Down Expand Up @@ -2081,6 +2134,21 @@ async function sendJobConclusionSpan(spanName, options = {}) {
if (typeof aiCredits === "number") {
attributes.push(buildAttr("gh-aw.aic", aiCredits));
}
// For the conclusion job, aggregate AI credits from per-job usage JSONL files
// that reside in /tmp/gh-aw/ and must be read before the temp folder is deleted
// in the post-step. These files are written by the agent and detection jobs
// and carry the actual AI credit consumption for each job in the workflow run.
// The conclusion job is not in jobEmitsOwnTokenUsage, so aiCredits is undefined
// above and no gh-aw.aic attribute has been pushed yet — these files are the
// sole source of AIC for conclusion spans.
if (jobName === "conclusion" && typeof aiCredits !== "number") {
const agentsAIC = parseAICreditsFromUsageJsonl(AGENTS_USAGE_JSONL_PATH);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot add logging in parseAICreditsFromUsageJsonl if the file exists

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in commit feat: update usage file paths. The function now logs when the file exists, when it doesn't exist, and if there's an error reading it.

const detectionAIC = parseAICreditsFromUsageJsonl(DETECTION_USAGE_JSONL_PATH);
const usageFileAIC = agentsAIC + detectionAIC;
if (usageFileAIC > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent data loss when usage files exist with all-zero credits: usageFileAIC > 0 conflates "zero credits from valid data" with "no usage files found". Both cases produce no gh-aw.aic attribute on the conclusion span.

💡 Why this matters and a suggested fix

The comment at line 2125–2129 explicitly states that for jobEmitsOwnTokenUsage jobs, gh-aw.aic is always emitted (defaulting to 0) so Sentry EAP infers the field as numeric and aggregations work without manual schema configuration. If parseAICreditsFromUsageJsonl reads two valid files that happen to sum to 0 (e.g., a free-tier / cached run), the conclusion span will silently omit gh-aw.aic, making it impossible to distinguish "AI ran but used 0 credits" from "files were missing entirely".

The function already returns 0 for absent files (caught ENOENT → return 0) AND for valid files summing to 0, so the > 0 guard cannot distinguish the two cases.

Consider tracking file presence separately:

if (jobName === "conclusion" && typeof aiCredits !== "number") {
  const agentsAIC = parseAICreditsFromUsageJsonl(AGENTS_USAGE_JSONL_PATH);
  const detectionAIC = parseAICreditsFromUsageJsonl(DETECTION_USAGE_JSONL_PATH);
  // Only emit when at least one file was actually present
  const eitherFilePresent =
    fileExistsSync(AGENTS_USAGE_JSONL_PATH) ||
    fileExistsSync(DETECTION_USAGE_JSONL_PATH);
  if (eitherFilePresent) {
    attributes.push(buildAttr("gh-aw.aic", agentsAIC + detectionAIC));
  }
}

Or refactor parseAICreditsFromUsageJsonl to return { total, readable } so callers can distinguish between a missing file and a zero-credit file.

attributes.push(buildAttr("gh-aw.aic", usageFileAIC));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot provide agent vs detection aic as well in 2 other attributes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in commit 45875f0. The conclusion job now emits three separate AIC attributes:

  • gh-aw.aic - combined total from both sources
  • gh-aw.aic.agent - agent-only credits
  • gh-aw.aic.detection - detection-only credits

Each attribute is only emitted when its value is greater than 0.

}
}
if (typeof runtimeMetrics.turns === "number") {
attributes.push(buildAttr("gh-aw.turns", runtimeMetrics.turns));
}
Expand Down Expand Up @@ -2398,6 +2466,9 @@ module.exports = {
resolveEngineId,
GITHUB_RATE_LIMITS_JSONL_PATH,
FAILURE_CATEGORIES_PATH,
AGENTS_USAGE_JSONL_PATH,
DETECTION_USAGE_JSONL_PATH,
parseAICreditsFromUsageJsonl,
sendJobSetupSpan,
sendJobConclusionSpan,
OTEL_JSONL_PATH,
Expand Down
232 changes: 232 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const {
parseOTLPCustomAttributes,
buildCustomOTLPAttributes,
FAILURE_CATEGORIES_PATH,
AGENTS_USAGE_JSONL_PATH,
DETECTION_USAGE_JSONL_PATH,
parseAICreditsFromUsageJsonl,
} = await import("./send_otlp_span.cjs");

const { readExperimentAssignments, EXPERIMENT_ASSIGNMENTS_PATH } = await import("./experiment_helpers.cjs");
Expand Down Expand Up @@ -6756,3 +6759,232 @@ describe("sendJobConclusionSpan does not emit OTLP metrics", () => {
expect(traceCalls.length).toBeGreaterThan(0);
});
});

// ---------------------------------------------------------------------------
// parseAICreditsFromUsageJsonl
// ---------------------------------------------------------------------------

describe("parseAICreditsFromUsageJsonl", () => {
let readFileSpy;

beforeEach(() => {
readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
});

afterEach(() => {
readFileSpy.mockRestore();
});

it("returns 0 when the file does not exist", () => {
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0);
});

it("returns 0 for an empty file", () => {
readFileSpy.mockImplementation(() => "");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0);
});

it("returns 0 for a file with only whitespace", () => {
readFileSpy.mockImplementation(() => " \n \n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0);
});

it("sums ai_credits from a single-entry file", () => {
readFileSpy.mockImplementation(() => JSON.stringify({ ai_credits: 1.5, model: "gpt-4o" }) + "\n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(1.5);
});

it("sums ai_credits across multiple entries", () => {
const lines = [JSON.stringify({ ai_credits: 1.0, model: "gpt-4o" }), JSON.stringify({ ai_credits: 0.5, model: "claude-3-5-sonnet" }), JSON.stringify({ ai_credits: 0.25 })].join("\n");
readFileSpy.mockImplementation(() => lines);
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(1.75);
});

it("supports camelCase aiCredits field", () => {
readFileSpy.mockImplementation(() => JSON.stringify({ aiCredits: 2.0 }) + "\n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(2.0);
});

it("prefers snake_case ai_credits over camelCase aiCredits when both are present", () => {
readFileSpy.mockImplementation(() => JSON.stringify({ ai_credits: 1.0, aiCredits: 9.0 }) + "\n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(1.0);
});

it("skips entries without an ai_credits or aiCredits field", () => {
const lines = [JSON.stringify({ model: "gpt-4o", input_tokens: 100 }), JSON.stringify({ ai_credits: 0.75 })].join("\n");
readFileSpy.mockImplementation(() => lines);
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0.75);
});

it("skips malformed JSON lines without throwing", () => {
const lines = ["{not valid json}", JSON.stringify({ ai_credits: 1.0 }), "also bad"].join("\n");
readFileSpy.mockImplementation(() => lines);
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(1.0);
});

it("skips entries with negative ai_credits", () => {
const lines = [JSON.stringify({ ai_credits: -0.5 }), JSON.stringify({ ai_credits: 1.0 })].join("\n");
readFileSpy.mockImplementation(() => lines);
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(1.0);
});

it("skips entries where ai_credits is a non-numeric string", () => {
const lines = [JSON.stringify({ ai_credits: "invalid" }), JSON.stringify({ ai_credits: 0.5 })].join("\n");
readFileSpy.mockImplementation(() => lines);
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0.5);
});

it("accepts ai_credits expressed as a numeric string", () => {
readFileSpy.mockImplementation(() => JSON.stringify({ ai_credits: "1.234" }) + "\n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBeCloseTo(1.234);
});

it("accepts ai_credits of zero", () => {
readFileSpy.mockImplementation(() => JSON.stringify({ ai_credits: 0 }) + "\n");
expect(parseAICreditsFromUsageJsonl("/tmp/gh-aw/agent_usage.jsonl")).toBe(0);
});

it("uses the AGENTS_USAGE_JSONL_PATH constant for the agents file", () => {
expect(AGENTS_USAGE_JSONL_PATH).toBe("/tmp/gh-aw/agent_usage.jsonl");
});

it("uses the DETECTION_USAGE_JSONL_PATH constant for the detection file", () => {
expect(DETECTION_USAGE_JSONL_PATH).toBe("/tmp/gh-aw/detection_usage.jsonl");
});
});

// ---------------------------------------------------------------------------
// sendJobConclusionSpan — conclusion job usage file aggregation
// ---------------------------------------------------------------------------

describe("sendJobConclusionSpan conclusion job AI credits from usage files", () => {
/** @type {Record<string, string | undefined>} */
const savedEnv = {};
const envKeys = ["GH_AW_OTLP_ENDPOINTS", "INPUT_JOB_NAME", "GH_AW_AIC", "GH_AW_AGENT_CONCLUSION", "GITHUB_RUN_ID", "GITHUB_ACTOR", "GITHUB_REPOSITORY"];
let mkdirSpy, appendSpy, readFileSpy;

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }));
for (const k of envKeys) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
mkdirSpy = vi.spyOn(fs, "mkdirSync").mockImplementation(() => {});
appendSpy = vi.spyOn(fs, "appendFileSync").mockImplementation(() => {});
readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
process.env.INPUT_JOB_NAME = "conclusion";
});

afterEach(() => {
vi.unstubAllGlobals();
for (const k of envKeys) {
if (savedEnv[k] !== undefined) {
process.env[k] = savedEnv[k];
} else {
delete process.env[k];
}
}
mkdirSpy.mockRestore();
appendSpy.mockRestore();
readFileSpy.mockRestore();
});

/**
* Extract attributes from the first span in the first fetch call body.
* @param {import("vitest").MockInstance} mockFetch
* @returns {Record<string, unknown>}
*/
function getSpanAttrMap(mockFetch) {
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
return Object.fromEntries(span.attributes.map(a => [a.key, a.value.doubleValue ?? a.value.intValue ?? a.value.stringValue ?? a.value.boolValue]));
}

it("emits gh-aw.aic from agent_usage.jsonl when only that file is present", async () => {
readFileSpy.mockImplementation(filePath => {
if (filePath === AGENTS_USAGE_JSONL_PATH) return JSON.stringify({ ai_credits: 1.5 }) + "\n";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeCloseTo(1.5);
});

it("emits gh-aw.aic from detection_usage.jsonl when only that file is present", async () => {
readFileSpy.mockImplementation(filePath => {
if (filePath === DETECTION_USAGE_JSONL_PATH) return JSON.stringify({ ai_credits: 0.75 }) + "\n";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeCloseTo(0.75);
});

it("emits gh-aw.aic as the sum of both usage files when both are present", async () => {
readFileSpy.mockImplementation(filePath => {
if (filePath === AGENTS_USAGE_JSONL_PATH) return JSON.stringify({ ai_credits: 1.0 }) + "\n" + JSON.stringify({ ai_credits: 0.5 }) + "\n";
if (filePath === DETECTION_USAGE_JSONL_PATH) return JSON.stringify({ ai_credits: 0.25 }) + "\n";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeCloseTo(1.75);
});

it("does not emit gh-aw.aic when both usage files are absent", async () => {
await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeUndefined();
});

it("does not emit gh-aw.aic when both usage files are empty", async () => {
readFileSpy.mockImplementation(filePath => {
if (filePath === AGENTS_USAGE_JSONL_PATH || filePath === DETECTION_USAGE_JSONL_PATH) return "";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeUndefined();
});

it("does not emit gh-aw.aic from usage files for non-conclusion jobs", async () => {
process.env.INPUT_JOB_NAME = "safe_outputs";
readFileSpy.mockImplementation(filePath => {
if (filePath === AGENTS_USAGE_JSONL_PATH) return JSON.stringify({ ai_credits: 5.0 }) + "\n";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.safe-outputs.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeUndefined();
});

it("reads usage files from the fixed /tmp/gh-aw/ paths regardless of GH_AW_AGENT_OUTPUT", async () => {
process.env.GH_AW_AGENT_OUTPUT = "/custom/path/output.json";
readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_usage.jsonl") return JSON.stringify({ ai_credits: 2.0 }) + "\n";
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.conclusion.conclusion", { startMs: 1_700_000_000_000 });

const attrs = getSpanAttrMap(fetch);
expect(attrs["gh-aw.aic"]).toBeCloseTo(2.0);
delete process.env.GH_AW_AGENT_OUTPUT;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test isolation bug: GH_AW_AGENT_OUTPUT is set in the test body but cleaned up with delete on the last line — not in afterEach. If sendJobConclusionSpan throws or any expect assertion fails, the env var leaks to subsequent tests in this suite.

💡 Suggested fix

Add GH_AW_AGENT_OUTPUT to envKeys so the beforeEach/afterEach cycle saves and restores it with all the other keys:

const envKeys = [
  "GH_AW_OTLP_ENDPOINTS", "INPUT_JOB_NAME", "GH_AW_AIC",
  "GH_AW_AGENT_CONCLUSION", "GITHUB_RUN_ID", "GITHUB_ACTOR",
  "GITHUB_REPOSITORY",
  "GH_AW_AGENT_OUTPUT",  // add this
];

Then remove the manual delete process.env.GH_AW_AGENT_OUTPUT at the end of the test — afterEach will handle it.

Without this, a mid-test failure (thrown exception in sendJobConclusionSpan or a jest/vitest abort) leaves GH_AW_AGENT_OUTPUT set for all later tests in the describe block, potentially masking or misreporting failures.

});
});
10 changes: 10 additions & 0 deletions pkg/constants/job_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ const SafeOutputsFilename = "safeoutputs.jsonl"
// consume structured token data without parsing the step summary or GITHUB_OUTPUT.
const TokenUsageFilename = "agent_usage.json"

// AgentUsageJsonlFilename is the filename of the per-run agent AI credit usage JSONL file written to /tmp/gh-aw/.
Comment thread
pelikhan marked this conversation as resolved.
Outdated
// Each line is a JSON object with an ai_credits (or aiCredits) field tracking credit consumption.
// This file is read by the conclusion job post-step to aggregate AIC before the temp folder is removed.
const AgentUsageJsonlFilename = "agent_usage.jsonl"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice — extracting these JSONL filenames into named constants keeps the Go and JS sides in sync. The doc comments are clear about where the file lives and what each line contains. 👍


// DetectionUsageJsonlFilename is the filename of the per-run detection job AI credit usage JSONL file written to /tmp/gh-aw/.
// Each line is a JSON object with an ai_credits (or aiCredits) field tracking credit consumption.
Comment thread
pelikhan marked this conversation as resolved.
Outdated
// This file is read by the conclusion job post-step to aggregate AIC before the temp folder is removed.
const DetectionUsageJsonlFilename = "detection_usage.jsonl"

// GithubRateLimitsFilename is the filename of the GitHub API rate-limit log written to /tmp/gh-aw/.
// Each line is a JSON object recording the x-ratelimit-* headers (or rate-limit API snapshot)
// captured during github.rest API calls, enabling post-run analysis of rate-limit consumption.
Expand Down
10 changes: 5 additions & 5 deletions pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,12 +660,12 @@ func buildUsageArtifactUploadSteps(prefix string, pinAction func(string) string)
" run: |\n",
" mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection\n",
" echo \"Usage artifact source file status:\"\n",
" for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do\n",
fmt.Sprintf(" for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/%s /tmp/gh-aw/%s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do\n", constants.AgentUsageJsonlFilename, constants.DetectionUsageJsonlFilename),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good use of fmt.Sprintf with indexed args (%[1]s) to avoid repeating the filename constant. Consider a brief comment noting these paths must match the JS-side constants to avoid drift.

" [ -f \"$file\" ] && echo \"FOUND: $file\" || echo \"MISSING: $file\"\n",
" done\n",
" [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true\n",
" [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true\n",
fmt.Sprintf(" [ -f /tmp/gh-aw/%[1]s ] && cp /tmp/gh-aw/%[1]s /tmp/gh-aw/usage/%[1]s || true\n", constants.AgentUsageJsonlFilename),
fmt.Sprintf(" [ -f /tmp/gh-aw/%[1]s ] && cp /tmp/gh-aw/%[1]s /tmp/gh-aw/usage/%[1]s || true\n", constants.DetectionUsageJsonlFilename),
" [ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
Expand All @@ -683,8 +683,8 @@ func buildUsageArtifactUploadSteps(prefix string, pinAction func(string) string)
fmt.Sprintf(" name: %s\n", usageArtifactName),
" path: |\n",
" /tmp/gh-aw/usage/aw-info.jsonl\n",
" /tmp/gh-aw/usage/agent_usage.jsonl\n",
" /tmp/gh-aw/usage/detection_usage.jsonl\n",
" /tmp/gh-aw/usage/" + constants.AgentUsageJsonlFilename + "\n",
" /tmp/gh-aw/usage/" + constants.DetectionUsageJsonlFilename + "\n",
" /tmp/gh-aw/usage/agent/token_usage.jsonl\n",
" /tmp/gh-aw/usage/detection/token_usage.jsonl\n",
" if-no-files-found: ignore\n",
Expand Down
Loading