Repository navigation
Add work queue activity summary to the conclusion job #65144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // @ts-check | ||
| "use strict"; | ||
|
|
||
| const { loadDispatchCoordinatorSnapshot } = require("./dispatch_work_coordinator_mcp_server.cjs"); | ||
| const { replayTransactions } = require("./dispatch_work_coordinator_replay.cjs"); | ||
| const { readCoordinatorLog } = require("./dispatch_work_coordinator_store.cjs"); | ||
|
|
||
| /** | ||
| * @param {Record<string, string>} states | ||
| * @param {string} state | ||
| */ | ||
| function countState(states, state) { | ||
| return Object.values(states).filter(value => value === state).length; | ||
| } | ||
|
|
||
| /** | ||
| * @param {ReturnType<typeof loadDispatchCoordinatorSnapshot>} snapshot | ||
| * @param {ReturnType<typeof replayTransactions>} current | ||
| */ | ||
| function renderSummary(snapshot, current) { | ||
| const before = snapshot.projection; | ||
| const baseline = new Set(before.transactions.map(transaction => JSON.stringify(transaction))); | ||
| const added = current.transactions.filter(transaction => !baseline.has(JSON.stringify(transaction))); | ||
| const workCount = Object.keys(current.work).length; | ||
| const lines = [ | ||
| "### Work queue activity", | ||
| "", | ||
| `${workCount} work item${workCount === 1 ? "" : "s"} in the queue; ${added.length} new transaction${added.length === 1 ? "" : "s"} observed since activation.`, | ||
| "", | ||
| "<details>", | ||
| "<summary>Show work queue activity</summary>", | ||
| "", | ||
| "This compares the activation snapshot with the queue read during conclusion. The queue is shared: changes may include activity from other workflow runs.", | ||
| "", | ||
| "| Work state | At activation | At conclusion |", | ||
| "| --- | ---: | ---: |", | ||
| ]; | ||
| for (const state of ["available", "claimed", "completed", "cancelled"]) { | ||
| lines.push(`| ${state} | ${countState(before.work, state)} | ${countState(current.work, state)} |`); | ||
| } | ||
| lines.push("", "| Claim state | At activation | At conclusion |", "| --- | ---: | ---: |"); | ||
| for (const state of ["effective", "superseded", "cancelled"]) { | ||
| lines.push(`| ${state} | ${countState(before.claim, state)} | ${countState(current.claim, state)} |`); | ||
| } | ||
| lines.push("", "| Activity since activation | Transactions |", "| --- | ---: |"); | ||
| for (const [kind, label] of [ | ||
| ["Work", "Work added"], | ||
| ["Claim", "Claims created"], | ||
| ["ClaimCancellation", "Claims cancelled"], | ||
| ["Completion", "Work completed"], | ||
| ["WorkCancellation", "Work cancelled"], | ||
| ]) { | ||
| lines.push(`| ${label} | ${added.filter(transaction => transaction.kind === kind).length} |`); | ||
| } | ||
| lines.push("", `Unique transactions: ${before.transactions.length} at activation; ${current.transactions.length} at conclusion.`); | ||
| if (snapshot.worker) { | ||
| const work = Object.hasOwn(current.work, snapshot.worker.work_id) ? current.work[snapshot.worker.work_id] : "absent"; | ||
| const claim = Object.hasOwn(current.claim, snapshot.worker.claim_id) ? current.claim[snapshot.worker.claim_id] : "absent"; | ||
| lines.push("", `Assigned worker: work **${work}**; claim **${claim}**.`); | ||
| } else { | ||
| lines.push("", "No worker claim was assigned to this run."); | ||
| } | ||
| lines.push("", "</details>", ""); | ||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| async function main(options = {}) { | ||
| const coreApi = options.core || core; | ||
| let summary; | ||
| try { | ||
| const snapshot = loadDispatchCoordinatorSnapshot(options.snapshotPath); | ||
| const githubClient = options.githubClient || github; | ||
| const repositoryContext = options.context || context; | ||
| const readLog = options.readCoordinatorLog || readCoordinatorLog; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Naming shadow: 💡 Suggested renameconst { readCoordinatorLog: defaultReadCoordinatorLog } = require("./dispatch_work_coordinator_store.cjs");
// ...
const readLog = options.readCoordinatorLog || defaultReadCoordinatorLog;This keeps the same injectable-dependency pattern used well throughout the test suite ( @copilot please address this. |
||
| const latest = await readLog({ | ||
| githubClient, | ||
| owner: repositoryContext.repo.owner, | ||
| repo: repositoryContext.repo.repo, | ||
| publishUpgrades: false, | ||
| core: coreApi, | ||
| }); | ||
| summary = renderSummary(snapshot, replayTransactions(latest.transactions)); | ||
| } catch (error) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] The catch-all swallows distinct failure modes (missing snapshot file, malformed snapshot, GitHub API error reading the log, or a replay/validation error for corrupt transaction data) into one generic warning. 💡 SuggestionConsider logging @copilot please address this. |
||
| coreApi.warning("Work queue activity summary is unavailable; the activation snapshot or current queue could not be read."); | ||
| await coreApi.summary.addRaw("### Work queue activity\n\n<details>\n<summary>Show work queue activity</summary>\n\nActivity is unavailable because the activation snapshot or current queue could not be read.\n\n</details>\n").write(); | ||
| throw new Error("Failed to summarize work queue activity", { cause: error }); | ||
| } | ||
| await coreApi.summary.addRaw(summary).write(); | ||
| } | ||
|
|
||
| module.exports = { main, renderSummary }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| // @ts-check | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
| import { main, renderSummary } from "./dispatch_work_coordinator_summary.cjs"; | ||
| import { replayTransactions, serializeTransactionLog } from "./dispatch_work_coordinator_replay.cjs"; | ||
|
|
||
| const work = id => ({ version: 1, kind: "Work", work: id, claim: null, attempt: null }); | ||
| const claim = (id, claimId) => ({ version: 1, kind: "Claim", work: id, claim: claimId, attempt: null }); | ||
| const initial = [work("done"), claim("done", "claim-a"), work("retry"), claim("retry", "claim-b"), work("cancel"), work("waiting")]; | ||
| const latest = [ | ||
| ...initial, | ||
| { version: 1, kind: "Completion", work: "done", claim: "claim-a", attempt: "run-1" }, | ||
| { version: 1, kind: "ClaimCancellation", work: "retry", claim: "claim-b", attempt: null }, | ||
| { version: 1, kind: "WorkCancellation", work: "cancel", claim: null, attempt: null }, | ||
| work("new"), | ||
| claim("new", "claim-c"), | ||
| claim("new", "claim-d"), | ||
| ]; | ||
| let tempDirectory; | ||
|
|
||
| function snapshot(transactions = initial, worker = null) { | ||
| return { sha: "head", worker, projection: replayTransactions(transactions) }; | ||
| } | ||
|
|
||
| function setup() { | ||
| tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-coordinator-summary-")); | ||
| const snapshotPath = path.join(tempDirectory, "snapshot.json"); | ||
| fs.writeFileSync(snapshotPath, JSON.stringify({ version: 2, sha: "head", worker: null, transactionLog: serializeTransactionLog(initial) })); | ||
| const summary = { addRaw: vi.fn(), write: vi.fn().mockResolvedValue(undefined) }; | ||
| summary.addRaw.mockReturnValue(summary); | ||
| const core = { summary, info: vi.fn(), warning: vi.fn() }; | ||
| return { snapshotPath, core, githubClient: {}, context: { repo: { owner: "owner", repo: "repo" } } }; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| if (tempDirectory) { | ||
| fs.rmSync(tempDirectory, { recursive: true, force: true }); | ||
| tempDirectory = undefined; | ||
| } | ||
| }); | ||
|
|
||
| describe("dispatch coordinator conclusion summary", () => { | ||
| it("shows queue state changes and every transaction kind in collapsed details", () => { | ||
| const summary = renderSummary(snapshot(), replayTransactions(latest)); | ||
| expect(summary).toContain("### Work queue activity\n\n5 work items in the queue; 6 new transactions observed since activation."); | ||
| expect(summary).toContain("<details>\n<summary>Show work queue activity</summary>\n\n"); | ||
| expect(summary).not.toContain("<details open"); | ||
| expect(summary).toContain("changes may include activity from other workflow runs"); | ||
| expect(summary).toContain("| available | 2 | 2 |"); | ||
| expect(summary).toContain("| claimed | 2 | 1 |"); | ||
| expect(summary).toContain("| completed | 0 | 1 |"); | ||
| expect(summary).toContain("| cancelled | 0 | 1 |"); | ||
| expect(summary).toContain("| effective | 2 | 2 |"); | ||
| expect(summary).toContain("| superseded | 0 | 1 |"); | ||
| expect(summary).toContain("| Claims created | 2 |"); | ||
| for (const label of ["Work added", "Claims cancelled", "Work completed", "Work cancelled"]) { | ||
| expect(summary).toContain(`| ${label} | 1 |`); | ||
| } | ||
| expect(summary).toContain("Unique transactions: 6 at activation; 12 at conclusion."); | ||
| expect(summary).toContain("No worker claim was assigned to this run."); | ||
| expect(summary).toMatch(/<\/details>\n$/); | ||
| expect(summary.indexOf("| Work state")).toBeGreaterThan(summary.indexOf("<details>")); | ||
| }); | ||
|
|
||
| it("deduplicates facts and does not count reordering as activity", () => { | ||
| const summary = renderSummary(snapshot(), replayTransactions([...initial].reverse().concat(initial))); | ||
| expect(summary).toContain("0 new transactions observed"); | ||
| expect(summary).toContain("Unique transactions: 6 at activation; 6 at conclusion."); | ||
| expect(summary).toContain("| Claims created | 0 |"); | ||
| }); | ||
|
|
||
| it("reports an empty queue explicitly", () => { | ||
| const summary = renderSummary(snapshot([]), replayTransactions([])); | ||
| expect(summary).toContain("0 work items in the queue; 0 new transactions"); | ||
| expect(summary).toContain("| available | 0 | 0 |"); | ||
| }); | ||
|
|
||
| it.each([ | ||
| [[], [work("one")], "1 work item in the queue; 1 new transaction observed since activation."], | ||
| [[work("one")], [work("one")], "1 work item in the queue; 0 new transactions observed since activation."], | ||
| [[work("one")], [work("one"), work("two")], "2 work items in the queue; 1 new transaction observed since activation."], | ||
| ])("pluralizes work items and new transactions independently", (before, after, expected) => { | ||
| expect(renderSummary(snapshot(before), replayTransactions(after))).toContain(expected); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ["done", "claim-a", "completed", "effective"], | ||
| ["retry", "claim-b", "available", "cancelled"], | ||
| ["new", "claim-d", "claimed", "superseded"], | ||
| ["missing", "__proto__", "absent", "absent"], | ||
| ])("reports worker state without disclosing identifiers for %s", (workId, claimId, workState, claimState) => { | ||
| const summary = renderSummary(snapshot(initial, { work_id: workId, claim_id: claimId }), replayTransactions(latest)); | ||
| expect(summary).toContain(`Assigned worker: work **${workState}**; claim **${claimState}**.`); | ||
| expect(summary).not.toContain(claimId); | ||
| }); | ||
|
|
||
| it("never renders untrusted queue identifiers or attempt data", () => { | ||
| const id = "</details>\n<script>secret</script>"; | ||
| const summary = renderSummary(snapshot([]), replayTransactions([work(id), claim(id, "`unsafe|claim`"), { version: 1, kind: "Completion", work: id, claim: "`unsafe|claim`", attempt: "sensitive-attempt" }])); | ||
| expect(summary).not.toContain(id); | ||
| expect(summary).not.toContain("unsafe"); | ||
| expect(summary).not.toContain("sensitive-attempt"); | ||
| expect(summary.match(/<\/details>/g)).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("writes the summary with a read-only refresh of the durable queue", async () => { | ||
| const options = setup(); | ||
| const readCoordinatorLog = vi.fn().mockResolvedValue({ sha: "latest", transactions: latest }); | ||
| await main({ ...options, readCoordinatorLog }); | ||
| expect(readCoordinatorLog).toHaveBeenCalledWith({ | ||
| githubClient: options.githubClient, | ||
| owner: "owner", | ||
| repo: "repo", | ||
| publishUpgrades: false, | ||
| core: options.core, | ||
| }); | ||
| expect(options.core.summary.addRaw).toHaveBeenCalledWith(renderSummary(snapshot(), replayTransactions(latest))); | ||
| expect(options.core.summary.write).toHaveBeenCalledOnce(); | ||
| expect(options.core.warning).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("reads legacy queue data without publishing upgrades", async () => { | ||
| const options = setup(); | ||
| const legacyWork = { kind: "Work", work: "legacy", claim: null, attempt: null }; | ||
| const createBlob = vi.fn(); | ||
| const githubClient = { | ||
| rest: { | ||
| git: { | ||
| getRef: async () => ({ data: { object: { sha: "legacy-head" } } }), | ||
| getCommit: async () => ({ data: { tree: { sha: "tree" } } }), | ||
| getTree: async () => ({ data: { tree: [{ path: "dispatch-work-coordinator.jsonl", type: "blob", sha: "blob" }] } }), | ||
| getBlob: async () => ({ data: { encoding: "base64", content: Buffer.from(`${JSON.stringify(legacyWork)}\n`).toString("base64") } }), | ||
| createBlob, | ||
| }, | ||
| }, | ||
| }; | ||
| await main({ ...options, githubClient }); | ||
| expect(createBlob).not.toHaveBeenCalled(); | ||
| expect(options.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("1 work item in the queue; 1 new transaction")); | ||
| }); | ||
|
|
||
| it("treats a missing coordinator branch as an empty queue", async () => { | ||
| const options = setup(); | ||
| const githubClient = { | ||
| rest: { | ||
| git: { | ||
| getRef: async () => { | ||
| throw Object.assign(new Error("Not Found"), { status: 404 }); | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| await main({ ...options, githubClient }); | ||
| expect(options.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("0 work items in the queue; 0 new transactions")); | ||
| expect(options.core.warning).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does not misreport a summary write failure as unavailable queue data", async () => { | ||
| const options = setup(); | ||
| const readCoordinatorLog = vi.fn().mockResolvedValue({ transactions: initial }); | ||
| options.core.summary.write.mockRejectedValue(new Error("summary write failed")); | ||
| await expect(main({ ...options, readCoordinatorLog })).rejects.toThrow("summary write failed"); | ||
| expect(options.core.warning).not.toHaveBeenCalled(); | ||
| expect(options.core.summary.addRaw).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it.each(["missing snapshot", "malformed snapshot", "failed refresh", "invalid queue"])("reports %s as unavailable rather than an empty queue", async failure => { | ||
| const options = setup(); | ||
| const readCoordinatorLog = vi.fn().mockResolvedValue({ sha: null, transactions: [] }); | ||
| if (failure === "missing snapshot") fs.unlinkSync(options.snapshotPath); | ||
| if (failure === "malformed snapshot") fs.writeFileSync(options.snapshotPath, '{"version":0}'); | ||
| if (failure === "failed refresh") readCoordinatorLog.mockRejectedValue(new Error("sensitive API error")); | ||
| if (failure === "invalid queue") readCoordinatorLog.mockResolvedValue({ transactions: [{ kind: "unknown", work: "sensitive" }] }); | ||
| await expect(main({ ...options, readCoordinatorLog })).rejects.toThrow("Failed to summarize work queue activity"); | ||
| expect(options.core.warning).toHaveBeenCalledOnce(); | ||
| const summary = options.core.summary.addRaw.mock.calls[0][0]; | ||
| expect(summary).toContain("<details>"); | ||
| expect(summary).toContain("Activity is unavailable"); | ||
| expect(summary).not.toContain("0 work items"); | ||
| expect(summary).not.toContain("sensitive"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,28 @@ func (c *Compiler) buildConclusionSetupSteps(data *WorkflowData) []string { | |
| return steps | ||
| } | ||
|
|
||
| func (c *Compiler) buildConclusionWorkQueueSummaryStep(data *WorkflowData) []string { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Smoke Test Review Comment (Run 37081441729) The function |
||
| if !isDispatchWorkCoordinatorEnabled(data) { | ||
| return nil | ||
| } | ||
| steps := buildArtifactDownloadSteps(ArtifactDownloadConfig{ | ||
| ArtifactName: artifactPrefixExprForDownstreamJob(data) + constants.ActivationArtifactName.String(), | ||
| DownloadPath: constants.TmpGhAwDirSlash, | ||
| StepName: "Download activation artifact for work queue summary", | ||
| IfCondition: "always()", | ||
| }, c.getActionPin) | ||
| return append(steps, | ||
| " - name: Summarize work queue activity\n", | ||
| " if: always()\n", | ||
| " continue-on-error: true\n", | ||
| fmt.Sprintf(" uses: %s\n", c.getActionPin("actions/github-script")), | ||
| " with:\n", | ||
| " script: |\n", | ||
| " const { main } = require('${{ runner.temp }}/gh-aw/actions/dispatch_work_coordinator_summary.cjs');\n", | ||
| " await main({ core, githubClient: github, context });\n", | ||
| ) | ||
| } | ||
|
|
||
| // buildConclusionNoOpStep builds the merged no-op handler step. | ||
| func (c *Compiler) buildConclusionNoOpStep(data *WorkflowData, mainJobName string) []string { | ||
| // Add noop processing step if noop is configured. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Smoke Test Review Comment (Run 37081441729)
Consider adding a JSDoc comment here to explain what
loadDispatchCoordinatorSnapshotdoes and what the expected snapshot structure looks like. This would improve maintainability as the module grows.