Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .changeset/operational-value-run-created-at.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 43 additions & 14 deletions actions/setup/js/generate_aw_info.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,50 @@ const { validateContextVariables } = require("./validate_context_variables.cjs")
const validateLockdownRequirements = require("./validate_lockdown_requirements.cjs");
const { writeMergedModelsJSON } = require("./merge_frontmatter_models.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { normalizeRunCreatedAt } = require("./run_created_at.cjs");
const { ERR_CONFIG, ERR_SYSTEM } = require("./error_codes.cjs");

// Retries guard the operational-value grader against transient run-metadata lookup failures.
const RUN_CREATED_AT_ATTEMPTS = 3;

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 move run create at logic into own file, add tests
Move constants to constants

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.

All created-at logic now lives in run_created_at.cjs (normalizeRunCreatedAt, fetchRunCreatedAt, resolveRunCreatedAtForInfo, resolveRunCreatedAtForGrading); generate_aw_info.cjs and trace_graders.cjs just call it. Retry constants moved to constants.cjs, and the tests moved into run_created_at.test.cjs. c25ee41

const RUN_CREATED_AT_RETRY_DELAY_MS = 500;

/**
* Resolve the authoritative workflow-run creation time as a UTC ISO timestamp.
* Consumers (the operational-value grader) require a valid timestamp, so transient
* lookup failures are retried before the value is reported as unavailable.
*
* @param {typeof import('@actions/core')} core - GitHub Actions core library
* @param {any} ctx - GitHub Actions context object
* @param {any} [githubClient] - Authenticated GitHub client; falls back to global.github
* @returns {Promise<string>} normalized timestamp, or "" when it cannot be resolved
*/
async function resolveRunCreatedAt(core, ctx, githubClient) {
let lastError = "workflow run creation time is unavailable";
for (let attempt = 1; attempt <= RUN_CREATED_AT_ATTEMPTS; attempt++) {

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 use withRetry

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.

Switched to withRetry from error_recovery.cjs (shared RUN_CREATED_AT_RETRY_CONFIG, transient-only shouldRetry), replacing the hand-rolled retry loop. c25ee41

try {
// @ts-ignore - global.github is set by setupGlobals() from github-script context
const github = githubClient || global.github;
const response = await github.rest.actions.getWorkflowRun({
owner: ctx.repo.owner,
repo: ctx.repo.repo,
run_id: ctx.runId,
});
const runCreatedAt = normalizeRunCreatedAt(response?.data?.created_at);
if (runCreatedAt) {
return runCreatedAt;
}
lastError = `workflow run creation time is not a valid timestamp: ${JSON.stringify(response?.data?.created_at)}`;
} catch (err) {
lastError = getErrorMessage(err);
}
if (attempt < RUN_CREATED_AT_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, RUN_CREATED_AT_RETRY_DELAY_MS));
}
}
core.warning(`Unable to load workflow-run creation time: ${lastError}`);
return "";
}

/**
* Generate aw_info.json with workflow run metadata.
* Reads compile-time values from environment variables (GH_AW_INFO_*) and
Expand Down Expand Up @@ -99,20 +141,7 @@ async function main(core, ctx, githubClient) {
};

if (process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT === "true") {
try {
// @ts-ignore - global.github is set by setupGlobals() from github-script context
const github = githubClient || global.github;
const response = await github.rest.actions.getWorkflowRun({
owner: ctx.repo.owner,
repo: ctx.repo.repo,
run_id: ctx.runId,
});
const runCreatedAt = response.data.created_at || "";
core.setOutput("run_created_at", runCreatedAt);
} catch (err) {
core.warning(`Unable to load workflow-run creation time: ${getErrorMessage(err)}`);
core.setOutput("run_created_at", "");
}
core.setOutput("run_created_at", await resolveRunCreatedAt(core, ctx, githubClient));
}

const frontmatterSource = process.env.GH_AW_INFO_FRONTMATTER_SOURCE || "";
Expand Down
29 changes: 29 additions & 0 deletions actions/setup/js/generate_aw_info.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,35 @@ describe("generate_aw_info.cjs", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "2026-08-24T12:00:00Z");
});

it("should normalize the run creation time to a UTC ISO timestamp", async () => {
process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT = "true";
mockGithub.rest.actions.getWorkflowRun.mockResolvedValue({ data: { created_at: "2026-08-24T14:00:00+02:00" } });

await main(mockCore, mockContext, mockGithub);

expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "2026-08-24T12:00:00Z");
});

it("should retry a transient run creation time lookup failure", async () => {
process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT = "true";
mockGithub.rest.actions.getWorkflowRun.mockRejectedValueOnce(new Error("server error")).mockResolvedValue({ data: { created_at: "2026-08-24T12:00:00Z" } });

await main(mockCore, mockContext, mockGithub);

expect(mockGithub.rest.actions.getWorkflowRun).toHaveBeenCalledTimes(2);
expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "2026-08-24T12:00:00Z");
});

it("should warn and clear the run creation time when the lookup returns no usable timestamp", async () => {
process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT = "true";
mockGithub.rest.actions.getWorkflowRun.mockResolvedValue({ data: { created_at: null } });

await main(mockCore, mockContext, mockGithub);

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Unable to load workflow-run creation time"));
expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "");
});

it("should include features from GH_AW_INFO_FEATURES and preserve value types", async () => {
process.env.GH_AW_INFO_FEATURES = JSON.stringify({
"gh-aw-detection": true,
Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/operational_value_grader.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ function buildRunSubject(env, metadata = {}) {
if (!/^\d+$/.test(runId) || runId === "0") {
throw new Error("GITHUB_RUN_ID must identify the workflow run");
}
if (metadata.createdAt !== undefined && metadata.createdAt !== null) {
// Reject malformed acquisition results instead of forwarding an invalid run subject.
parseTimestamp(metadata.createdAt, "run.createdAt");
}
return {
id: runId,
attempt: Number(env.GITHUB_RUN_ATTEMPT) || 1,
Expand Down
8 changes: 8 additions & 0 deletions actions/setup/js/operational_value_grader.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ describe("operational_value_grader", () => {
});
});

it("binds the run subject to an acquired creation time", () => {
expect(buildRunSubject(TEST_ENV, { createdAt: "2026-08-23T12:00:00Z" }).createdAt).toBe("2026-08-23T12:00:00Z");
});

it("rejects a malformed run creation time instead of building an invalid request", () => {
expect(() => buildRunSubject(TEST_ENV, { createdAt: "2026-08-23 12:00:00" })).toThrow("run.createdAt must be a UTC ISO-8601 timestamp");
});

it("returns absolute value with a secondary baseline delta", () => {
const output = executeOperationalValueEvaluator(
operationalValueEvaluator({
Expand Down
24 changes: 24 additions & 0 deletions actions/setup/js/run_created_at.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @ts-check

/**
* Normalize a workflow-run creation time into the strict UTC ISO-8601 form
* (`YYYY-MM-DDTHH:MM:SSZ`) expected by the operational-value grader contract.
*
* GitHub returns `created_at` as an ISO timestamp, but callers may also receive
* an empty string (unresolved workflow output) or an offset-bearing timestamp.
* Values that cannot be parsed are reported as an empty string so callers can
* treat the timestamp as unavailable rather than propagating an invalid value.
*
* @param {unknown} value
* @returns {string} normalized timestamp, or "" when the value is unusable
*/
function normalizeRunCreatedAt(value) {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (trimmed === "") return "";
const parsed = Date.parse(trimmed);
if (!Number.isFinite(parsed)) return "";
return new Date(parsed).toISOString().replace(/\.\d{3}Z$/, "Z");
}

module.exports = { normalizeRunCreatedAt };
23 changes: 23 additions & 0 deletions actions/setup/js/run_created_at.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @ts-check

const { normalizeRunCreatedAt } = require("./run_created_at.cjs");

describe("normalizeRunCreatedAt", () => {
it("keeps an already normalized UTC timestamp", () => {
expect(normalizeRunCreatedAt("2026-09-12T20:58:00Z")).toBe("2026-09-12T20:58:00Z");
});

it("normalizes offsets and millisecond precision", () => {
expect(normalizeRunCreatedAt("2026-09-12T22:58:00+02:00")).toBe("2026-09-12T20:58:00Z");
expect(normalizeRunCreatedAt("2026-09-12T20:58:00.553Z")).toBe("2026-09-12T20:58:00Z");
});

it("reports unusable values as empty", () => {
expect(normalizeRunCreatedAt("")).toBe("");
expect(normalizeRunCreatedAt(" ")).toBe("");
expect(normalizeRunCreatedAt("not-a-timestamp")).toBe("");
expect(normalizeRunCreatedAt(null)).toBe("");
expect(normalizeRunCreatedAt(undefined)).toBe("");
expect(normalizeRunCreatedAt(1757710680000)).toBe("");
});
});
55 changes: 53 additions & 2 deletions actions/setup/js/trace_graders.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { readExperimentAssignments } = require("./experiment_helpers.cjs");
const { calculateWorkingSetFromEntries } = require("./working_set_metrics.cjs");
const { executeOperationalValueEvaluator } = require("./operational_value_grader.cjs");
const { normalizeRunCreatedAt } = require("./run_created_at.cjs");

// --- Constants ---
const TMP_GH_AW = "/tmp/gh-aw";
Expand Down Expand Up @@ -670,6 +671,41 @@ function runOperationalValueGrader(id, evaluatorContent, meta, options) {
}
}

/**
* Resolve the authoritative workflow-run creation time for operational-value grading.
*
* The activation job normally publishes it through GH_AW_RUN_CREATED_AT. When that
* value is missing or unusable (for example the activation lookup failed), fall back
* to the Actions run metadata so the grader request is still bound to a run opportunity.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {any} [githubClient]
* @returns {Promise<{createdAt: string, error?: string}>}
*/
async function resolveRunCreatedAt(env = process.env, githubClient = undefined) {
const fromEnv = normalizeRunCreatedAt(env.GH_AW_RUN_CREATED_AT);
if (fromEnv) return { createdAt: fromEnv };

// @ts-ignore - global.github is set by setupGlobals() from the github-script context
const github = githubClient || global.github;
const repository = String(env.GITHUB_REPOSITORY || "");
const [owner, repo] = repository.split("/");
const runId = String(env.GITHUB_RUN_ID || "");
if (!github?.rest?.actions?.getWorkflowRun || !owner || !repo || !/^\d+$/.test(runId)) {
return { createdAt: "", error: "workflow run creation time is unavailable and cannot be read from Actions run metadata" };
}
try {
const response = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: Number(runId) });
const createdAt = normalizeRunCreatedAt(response?.data?.created_at);
if (!createdAt) {
return { createdAt: "", error: "Actions run metadata did not provide a valid workflow run creation time" };
}
return { createdAt };
} catch (err) {
return { createdAt: "", error: `failed to read workflow run creation time from Actions run metadata: ${getErrorMessage(err)}` };
}
}

function archiveOperationalValueEvaluator(evaluatorContent, expectedDigest, outputPath = OPERATIONAL_VALUE_EVALUATOR_PATH) {
const actualDigest = crypto.createHash("sha256").update(evaluatorContent, "utf8").digest("hex");
if (!expectedDigest || actualDigest !== expectedDigest) {
Expand Down Expand Up @@ -770,8 +806,17 @@ async function main(manifestB64, execSpecB64) {
} catch (err) {
core.warning(`Graders: failed to write payload: ${getErrorMessage(err)}`);
}
const runCreatedAt = process.env.GH_AW_RUN_CREATED_AT;
const operationalValueRunMetadata = runCreatedAt ? { createdAt: runCreatedAt } : undefined;
let operationalValueRunCreatedAtError;
let operationalValueRunMetadata;
if (operationalValueManifest) {
const resolved = await resolveRunCreatedAt();
if (resolved.createdAt) {
operationalValueRunMetadata = { createdAt: resolved.createdAt };
} else {
operationalValueRunCreatedAtError = resolved.error || "workflow run creation time is unavailable";
core.warning(`Graders: ${operationalValueRunCreatedAtError}`);
}
}

// Run all graders
/** @type {GraderResult[]} */
Expand All @@ -795,6 +840,11 @@ async function main(manifestB64, execSpecB64) {
result = normalizeResult(grader.id, null, meta);
result.status = "error";
result.error = `grader ${grader.id} runtime error: ${operationalValueEvaluatorArchiveError}`;
} else if (grader.source === "operational-value" && operationalValueRunCreatedAtError) {
result = normalizeResult(grader.id, null, meta);
result.status = "error";
result.error = `grader ${grader.id} runtime error: ${operationalValueRunCreatedAtError}`;
result.diagnostics = { missingReason: "run created-at acquisition failed" };
} else if (grader.source === "operational-value" && executionMap[grader.id]?.run) {
result = runOperationalValueGrader(grader.id, executionMap[grader.id].run, meta, { runMetadata: operationalValueRunMetadata });
} else if (executionMap[grader.id]?.script) {
Expand Down Expand Up @@ -866,6 +916,7 @@ module.exports = {
runBuiltinGrader,
runCustomGrader,
runOperationalValueGrader,
resolveRunCreatedAt,
normalizeResult,
buildGradersSummaryBody,
evaluateThreshold,
Expand Down
30 changes: 30 additions & 0 deletions actions/setup/js/trace_graders.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
runGrader,
runBuiltinGrader,
runCustomGrader,
resolveRunCreatedAt,
normalizeResult,
buildGradersSummaryBody,
evaluateThreshold,
Expand Down Expand Up @@ -196,6 +197,35 @@ describe("trace_graders", () => {
});
});

describe("resolveRunCreatedAt", () => {
it("normalizes the activation output to a UTC ISO timestamp", async () => {
await expect(resolveRunCreatedAt({ GH_AW_RUN_CREATED_AT: "2026-09-12T20:58:00.000Z" })).resolves.toEqual({ createdAt: "2026-09-12T20:58:00Z" });
});

it("falls back to Actions run metadata when the activation output is missing", async () => {
const getWorkflowRun = vi.fn().mockResolvedValue({ data: { created_at: "2026-09-12T20:58:00Z" } });
const resolved = await resolveRunCreatedAt({ GH_AW_RUN_CREATED_AT: "", GITHUB_REPOSITORY: "githubnext/gh-aw-cao", GITHUB_RUN_ID: "34718229379" }, { rest: { actions: { getWorkflowRun } } });

expect(getWorkflowRun).toHaveBeenCalledWith({ owner: "githubnext", repo: "gh-aw-cao", run_id: 34718229379 });
expect(resolved).toEqual({ createdAt: "2026-09-12T20:58:00Z" });
});

it("reports an acquisition failure when the fallback lookup fails", async () => {
const getWorkflowRun = vi.fn().mockRejectedValue(new Error("forbidden"));
const resolved = await resolveRunCreatedAt({ GITHUB_REPOSITORY: "githubnext/gh-aw-cao", GITHUB_RUN_ID: "34718229379" }, { rest: { actions: { getWorkflowRun } } });

expect(resolved.createdAt).toBe("");
expect(resolved.error).toContain("forbidden");
});

it("reports an acquisition failure when no lookup is possible", async () => {
const resolved = await resolveRunCreatedAt({ GITHUB_REPOSITORY: "", GITHUB_RUN_ID: "" }, null);

expect(resolved.createdAt).toBe("");
expect(resolved.error).toContain("unavailable");
});
});

describe("archiveOperationalValueEvaluator", () => {
it("writes only evaluator bytes matching the frozen digest", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "operational-value-evaluator-archive-"));
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/specs/graders-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ The reserved grader ID MUST be `operational-value`. It MUST NOT accept an inline

The compiler MUST resolve `run` within the repository, reject symlinks and non-regular files, validate Bash syntax prerequisites, freeze the evaluator bytes, and record their SHA-256 digest in the grader manifest and result implementation.

The implementation MUST acquire the workflow run creation time, from the activation job output or from Actions run metadata, and MUST bind it to `run.createdAt` in the grader request. When the creation time cannot be acquired, the implementation MUST report an explicit grader error and MUST NOT issue a grader request with a missing or malformed `run.createdAt`.

The evaluator MUST implement `--definition` and `--grade-run`. Its primary `value` MUST be absolute operational attainment in `[0,1]` or `null`. A baseline MAY be frozen separately; gh-aw MUST derive `deltaFromBaseline` and MUST NOT replace the primary value with that delta.

An operational-value observation MUST include:
Expand Down Expand Up @@ -233,6 +235,8 @@ All applicable files MUST be included in the unified `agent` artifact.

`grader_results.json` SHOULD include normalized run/result structures suitable for downstream programmatic reads, including per-grader value/status and run-level pass/fail/error counts.

Tooling that republishes grader results, including `gh aw logs --json` and its cached JSONL records, MUST preserve the result contract: `source`, `value`, `unit`, `implementation`, `observation`, `diagnostics`, `baselineValue`, and `deltaFromBaseline`. Metric units MUST NOT change during serialization.

---

## 9. Experiment Metric References
Expand Down
Loading