From 06443e54c1596a8432c60086fd40b8a6502d2894 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:53:51 +0000 Subject: [PATCH 1/9] Add dashboard usage reporting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agentic-workflows-dashboard/extension.mjs | 338 ++++++++++++- .../agentic-workflows-dashboard/web/app.js | 450 +++++++++++------- .../web/index.html | 69 ++- .../web/styles.css | 20 +- 4 files changed, 695 insertions(+), 182 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index 2638886d789..6eba99bcab3 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -11,6 +11,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const servers = new Map(); const cache = new Map(); // key → { data, expiresAt } const CACHE_TTL_MS = 60_000; +const DEFAULT_LOG_TIMEOUT_MINUTES = 1; +const DEFAULT_RUN_COUNT = 100; +const MAX_LOG_CONTINUATIONS = 6; +const REPORT_WINDOWS = { + "3d": { id: "3d", label: "3 days", startDate: "-3d", days: 3 }, + "7d": { id: "7d", label: "7 days", startDate: "-1w", days: 7 }, + "1mo": { id: "1mo", label: "1 month", startDate: "-1mo", days: 30 }, +}; let workspacePath = process.cwd(); // --------------------------------------------------------------------------- @@ -82,15 +90,192 @@ async function getExperiments() { return experiments; } -async function getRuns(count = 50) { - const key = `runs:${count}`; +function getReportWindow(windowId) { + return REPORT_WINDOWS[windowId] ?? REPORT_WINDOWS["7d"]; +} + +function normalizeLogsOptions(options = {}) { + const window = getReportWindow(options.window); + const count = Number.parseInt(String(options.count ?? DEFAULT_RUN_COUNT), 10); + const timeout = Number.parseInt(String(options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), 10); + const artifacts = Array.isArray(options.artifacts) && options.artifacts.length > 0 ? options.artifacts : ["usage"]; + + return { + window, + count: Number.isFinite(count) && count > 0 ? count : DEFAULT_RUN_COUNT, + timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_LOG_TIMEOUT_MINUTES, + startDate: typeof options.startDate === "string" && options.startDate.trim() ? options.startDate.trim() : window.startDate, + endDate: typeof options.endDate === "string" && options.endDate.trim() ? options.endDate.trim() : "", + beforeRunID: Number.isFinite(Number(options.beforeRunID)) && Number(options.beforeRunID) > 0 ? Number(options.beforeRunID) : 0, + afterRunID: Number.isFinite(Number(options.afterRunID)) && Number(options.afterRunID) > 0 ? Number(options.afterRunID) : 0, + workflowName: typeof options.workflowName === "string" ? options.workflowName.trim() : "", + engine: typeof options.engine === "string" ? options.engine.trim() : "", + branch: typeof options.branch === "string" ? options.branch.trim() : "", + artifacts, + }; +} + +function buildLogsArgs(options) { + const args = ["logs", "--json", "-c", String(options.count), "--timeout", String(options.timeout)]; + + if (options.workflowName) args.push(options.workflowName); + if (options.startDate) args.push("--start-date", options.startDate); + if (options.endDate) args.push("--end-date", options.endDate); + if (options.engine) args.push("--engine", options.engine); + if (options.branch) args.push("--ref", options.branch); + if (options.beforeRunID > 0) args.push("--before-run-id", String(options.beforeRunID)); + if (options.afterRunID > 0) args.push("--after-run-id", String(options.afterRunID)); + if (options.artifacts.length > 0) args.push("--artifacts", options.artifacts.join(",")); + + return args; +} + +function continuationToLogsOptions(continuation, fallback) { + if (!continuation) return null; + + return normalizeLogsOptions({ + window: fallback.window.id, + workflowName: continuation.workflow_name || fallback.workflowName, + count: continuation.count || fallback.count, + startDate: continuation.start_date || fallback.startDate, + endDate: continuation.end_date || fallback.endDate, + engine: continuation.engine || fallback.engine, + branch: continuation.branch || fallback.branch, + afterRunID: continuation.after_run_id || fallback.afterRunID, + beforeRunID: continuation.before_run_id || fallback.beforeRunID, + timeout: continuation.timeout || fallback.timeout, + artifacts: fallback.artifacts, + }); +} + +function mergeRuns(existingRuns, nextRuns) { + const merged = new Map(existingRuns.map(run => [run.run_id, run])); + for (const run of nextRuns) { + if (run?.run_id != null) { + merged.set(run.run_id, run); + } + } + return Array.from(merged.values()).sort((a, b) => Number(b.run_id ?? 0) - Number(a.run_id ?? 0)); +} + +async function getLogsData(options = {}) { + const normalized = normalizeLogsOptions(options); + const key = `logs:${JSON.stringify({ + window: normalized.window.id, + count: normalized.count, + timeout: normalized.timeout, + startDate: normalized.startDate, + endDate: normalized.endDate, + beforeRunID: normalized.beforeRunID, + afterRunID: normalized.afterRunID, + workflowName: normalized.workflowName, + engine: normalized.engine, + branch: normalized.branch, + artifacts: normalized.artifacts, + })}`; const hit = getCached(key); if (hit) return hit; - const raw = await runGhAw(["logs", "--json", "-c", String(count)]); - const logsData = JSON.parse(raw); - const runs = logsData.runs ?? []; - setCached(key, runs); - return runs; + + let current = normalized; + let logsFetches = 0; + let runs = []; + let continuation = null; + let summary = null; + + while (current && logsFetches < MAX_LOG_CONTINUATIONS) { + const raw = await runGhAw(buildLogsArgs(current)); + const data = JSON.parse(raw); + runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); + continuation = data?.continuation ?? null; + summary = data?.summary ?? summary; + logsFetches += 1; + + if (!continuation) { + break; + } + + current = continuationToLogsOptions(continuation, normalized); + } + + const result = { + runs, + summary, + window: normalized.window, + timeout: normalized.timeout, + logsFetches, + partial: Boolean(continuation), + continuation, + }; + setCached(key, result); + return result; +} + +async function getRuns(options = {}) { + return getLogsData(options); +} + +function toNumber(value) { + const numeric = Number(value ?? 0); + return Number.isFinite(numeric) ? numeric : 0; +} + +function buildUsageSummary(runs, window) { + const usageByWorkflow = new Map(); + + for (const run of runs) { + const workflowName = String(run?.workflow_name ?? "").trim(); + if (!workflowName) continue; + + const aic = toNumber(run?.aic); + const entry = usageByWorkflow.get(workflowName) ?? { + workflow_name: workflowName, + run_count: 0, + total_aic: 0, + cost_per_run: 0, + daily_aic: 0, + monthly_forecast_aic: 0, + last_run_at: "", + }; + + entry.run_count += 1; + entry.total_aic += aic; + const createdAt = typeof run?.created_at === "string" ? run.created_at : ""; + if (createdAt && (!entry.last_run_at || createdAt > entry.last_run_at)) { + entry.last_run_at = createdAt; + } + + usageByWorkflow.set(workflowName, entry); + } + + return Array.from(usageByWorkflow.values()) + .map(entry => { + const costPerRun = entry.run_count > 0 ? entry.total_aic / entry.run_count : 0; + const dailyAIC = window.days > 0 ? entry.total_aic / window.days : 0; + return { + ...entry, + cost_per_run: costPerRun, + daily_aic: dailyAIC, + monthly_forecast_aic: dailyAIC * 30, + }; + }) + .sort((a, b) => { + const dailyDelta = b.daily_aic - a.daily_aic; + if (dailyDelta !== 0) return dailyDelta; + return b.cost_per_run - a.cost_per_run; + }); +} + +async function getUsage(options = {}) { + const logsData = await getLogsData(options); + return { + items: buildUsageSummary(logsData.runs, logsData.window), + window: logsData.window, + timeout: logsData.timeout, + logsFetches: logsData.logsFetches, + partial: logsData.partial, + continuation: logsData.continuation, + total_runs: logsData.runs.length, + }; } // --------------------------------------------------------------------------- @@ -102,12 +287,68 @@ function parseGhAwArgs(raw) { return m ? m[1].trim().split(/\s+/) : null; } -async function execCommand(rawCmd) { +function hasFlag(args, longFlag, shortFlag = "") { + return args.some((arg, index) => arg === longFlag || arg.startsWith(`${longFlag}=`) || (shortFlag && (arg === shortFlag || arg.startsWith(`${shortFlag}=`))) || ((arg === longFlag || arg === shortFlag) && index < args.length - 1)); +} + +function logsCommandUsesJSON(args) { + return hasFlag(args, "--json"); +} + +function normalizeLogsCommandArgs(args, windowId, timeout) { + const nextArgs = [...args]; + if (!hasFlag(nextArgs, "--start-date") && !hasFlag(nextArgs, "--end-date") && !hasFlag(nextArgs, "--after-run-id") && !hasFlag(nextArgs, "--before-run-id")) { + nextArgs.push("--start-date", getReportWindow(windowId).startDate); + } + if (!hasFlag(nextArgs, "--timeout")) { + nextArgs.push("--timeout", String(timeout)); + } + if (!hasFlag(nextArgs, "--artifacts")) { + nextArgs.push("--artifacts", "usage"); + } + return nextArgs; +} + +async function execCommand(rawCmd, options = {}) { const args = parseGhAwArgs(rawCmd); if (!args) { return { command: rawCmd, output: "Only 'gh aw ' commands are supported.", error: true }; } try { + if (args[0] === "logs" && logsCommandUsesJSON(args)) { + const commandArgs = normalizeLogsCommandArgs(args, options.window, options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES); + const output = await runGhAw(commandArgs); + const firstBatch = JSON.parse(output); + let runs = Array.isArray(firstBatch?.runs) ? firstBatch.runs : []; + let continuation = firstBatch?.continuation ?? null; + let logsFetches = 1; + let current = continuationToLogsOptions(continuation, normalizeLogsOptions({ window: options.window, timeout: options.timeout })); + + while (continuation && current && logsFetches < MAX_LOG_CONTINUATIONS) { + const continuationOutput = await runGhAw(buildLogsArgs(current)); + const continuationBatch = JSON.parse(continuationOutput); + runs = mergeRuns(runs, Array.isArray(continuationBatch?.runs) ? continuationBatch.runs : []); + continuation = continuationBatch?.continuation ?? null; + current = continuationToLogsOptions(continuation, current); + logsFetches += 1; + } + + return { + command: `gh aw ${commandArgs.join(" ")}`, + output: JSON.stringify( + { + ...firstBatch, + runs, + partial: Boolean(continuation), + logs_fetches: logsFetches, + continuation, + }, + null, + 2 + ), + }; + } + const output = await runGhAw(args); return { command: rawCmd, output }; } catch (err) { @@ -166,11 +407,29 @@ async function startServer() { } else if (pathname === "/api/experiments") { sendJson(await getExperiments()); } else if (pathname === "/api/runs") { - const count = parseInt(reqUrl.searchParams.get("count") ?? "50", 10); - sendJson(await getRuns(count)); + sendJson( + await getRuns({ + count: parseInt(reqUrl.searchParams.get("count") ?? String(DEFAULT_RUN_COUNT), 10), + window: reqUrl.searchParams.get("window") ?? "7d", + timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), + }) + ); + } else if (pathname === "/api/usage") { + sendJson( + await getUsage({ + count: parseInt(reqUrl.searchParams.get("count") ?? String(DEFAULT_RUN_COUNT), 10), + window: reqUrl.searchParams.get("window") ?? "7d", + timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), + }) + ); } else if (pathname === "/api/run-command") { const cmd = reqUrl.searchParams.get("cmd") ?? ""; - sendJson(await execCommand(cmd)); + sendJson( + await execCommand(cmd, { + window: reqUrl.searchParams.get("window") ?? "7d", + timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), + }) + ); } else if (pathname === "/api/refresh") { cache.clear(); sendJson({ ok: true }); @@ -201,7 +460,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman **CLI commands used by this canvas:** - \`gh aw status --json\` — list agentic workflow definitions (workflow, engine_id, compiled, labels, status, time_remaining) -- \`gh aw logs --json -c \` — list recent workflow runs (run_id, workflow_name, status, conclusion, duration, token_usage, turns, error_count) +- \`gh aw logs --json -c --start-date --timeout \` — list recent workflow runs and follow continuation batches progressively - \`gh aw experiments list --json\` — list experiment workflow branches (workflow_id, branch, experiments, total_runs, last_run) **Dev build** (when gh-aw is not installed as a gh extension): @@ -210,7 +469,8 @@ It never calls Go code directly — all data is fetched by running CLI subcomman **Canvas actions available to the agent:** - \`listDefinitions\` — calls \`gh aw status --json\`, returns paged results -- \`listRuns\` — calls \`gh aw logs --json\`, returns paged results +- \`listRuns\` — calls \`gh aw logs --json\` with a selected report window, timeout, and continuation handling +- \`listUsage\` — aggregates workflow AIC usage, daily burn, and monthly forecast from the same logs window - \`listExperiments\` — calls \`gh aw experiments list --json\`, returns paged results - \`getRun\` — looks up a single run by \`run_id\` - \`runCommand\` — executes any \`gh aw \` and returns stdout @@ -241,19 +501,59 @@ It never calls Go code directly — all data is fetched by running CLI subcomman }, { name: "listRuns", - description: "List recent workflow runs via gh aw logs --json, with paging.", + description: "List recent workflow runs via gh aw logs --json, with paging and continuation handling.", + inputSchema: { + type: "object", + properties: { + page: { type: "number", minimum: 1 }, + pageSize: { type: "number", minimum: 1, maximum: 100 }, + count: { type: "number", minimum: 1, maximum: 200, description: "Max runs to fetch from the CLI." }, + window: { type: "string", enum: ["3d", "7d", "1mo"], description: "Report window preset for gh aw logs." }, + timeout: { type: "number", minimum: 1, maximum: 10, description: "Per-request timeout in minutes for progressive logs retrieval." }, + }, + additionalProperties: false, + }, + handler: async ctx => { + const logsData = await getRuns({ + count: Number(ctx.input?.count ?? DEFAULT_RUN_COUNT), + window: String(ctx.input?.window ?? "7d"), + timeout: Number(ctx.input?.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), + }); + return { + ...paginate(logsData.runs, Number(ctx.input?.page ?? 1), Number(ctx.input?.pageSize ?? 20)), + partial: logsData.partial, + logsFetches: logsData.logsFetches, + window: logsData.window, + }; + }, + }, + { + name: "listUsage", + description: "Aggregate workflow AIC usage, daily burn, and monthly forecast from gh aw logs.", inputSchema: { type: "object", properties: { page: { type: "number", minimum: 1 }, pageSize: { type: "number", minimum: 1, maximum: 100 }, count: { type: "number", minimum: 1, maximum: 200, description: "Max runs to fetch from the CLI." }, + window: { type: "string", enum: ["3d", "7d", "1mo"], description: "Report window preset for gh aw logs." }, + timeout: { type: "number", minimum: 1, maximum: 10, description: "Per-request timeout in minutes for progressive logs retrieval." }, }, additionalProperties: false, }, handler: async ctx => { - const runs = await getRuns(Number(ctx.input?.count ?? 50)); - return paginate(runs, Number(ctx.input?.page ?? 1), Number(ctx.input?.pageSize ?? 20)); + const usage = await getUsage({ + count: Number(ctx.input?.count ?? DEFAULT_RUN_COUNT), + window: String(ctx.input?.window ?? "7d"), + timeout: Number(ctx.input?.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), + }); + return { + ...paginate(usage.items, Number(ctx.input?.page ?? 1), Number(ctx.input?.pageSize ?? 20)), + partial: usage.partial, + logsFetches: usage.logsFetches, + totalRuns: usage.total_runs, + window: usage.window, + }; }, }, { @@ -282,8 +582,8 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const runs = await getRuns(200); - return { run: runs.find(r => r.run_id === Number(ctx.input?.run_id)) ?? null }; + const logsData = await getRuns({ count: 200, window: "1mo", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }); + return { run: logsData.runs.find(r => r.run_id === Number(ctx.input?.run_id)) ?? null }; }, }, { @@ -295,7 +595,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman properties: { command: { type: "string", description: "Full command string starting with 'gh aw'." } }, additionalProperties: false, }, - handler: async ctx => execCommand(String(ctx.input?.command ?? "")), + handler: async ctx => execCommand(String(ctx.input?.command ?? ""), { window: "7d", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }), }, { name: "refresh", diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index dd579786c02..7e16758bac5 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/app.js +++ b/.github/extensions/agentic-workflows-dashboard/web/app.js @@ -2,187 +2,317 @@ import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"; import { paginate } from "./pagination.js"; const dashboardTabs = [ - { id: "definitions", label: "Workflows", counter: "definitions" }, - { id: "runs", label: "Runs", counter: "runs" }, - { id: "details", label: "Run details" }, - { id: "experiments", label: "Experiments", counter: "experiments" }, - { id: "commands", label: "Commands" }, + { id: "definitions", label: "Workflows", counter: "definitions" }, + { id: "runs", label: "Runs", counter: "runs" }, + { id: "details", label: "Run details" }, + { id: "usage", label: "Usage", counter: "usage" }, + { id: "experiments", label: "Experiments", counter: "experiments" }, + { id: "commands", label: "Commands" }, +]; + +const reportWindows = [ + { id: "3d", label: "3 days", startDate: "-3d" }, + { id: "7d", label: "7 days", startDate: "-1w" }, + { id: "1mo", label: "1 month", startDate: "-1mo" }, ]; function runStatusClass(run) { - const s = run?.status ?? ""; - const c = run?.conclusion ?? ""; - if (s === "completed" || s === "success") { - return c && c !== "success" ? "Label Label--danger" : "Label Label--success"; - } - if (s === "failure" || s === "failed") return "Label Label--danger"; - if (s === "in_progress" || s === "running") return "Label Label--attention"; - return "Label Label--secondary"; + const status = run?.status ?? ""; + const conclusion = run?.conclusion ?? ""; + if (status === "completed" || status === "success") { + return conclusion && conclusion !== "success" ? "Label Label--danger" : "Label Label--success"; + } + if (status === "failure" || status === "failed") return "Label Label--danger"; + if (status === "in_progress" || status === "running") return "Label Label--attention"; + return "Label Label--secondary"; } function runStatusLabel(run) { - if (run?.status === "completed" && run?.conclusion) return run.conclusion; - return run?.status ?? "unknown"; + if (run?.status === "completed" && run?.conclusion) return run.conclusion; + return run?.status ?? "unknown"; } function definitionStatusClass(def) { - if (def?.status === "disabled") return "Label Label--secondary"; - return def?.compiled === "yes" ? "Label Label--success" : "Label Label--attention"; + if (def?.status === "disabled") return "Label Label--secondary"; + return def?.compiled === "yes" ? "Label Label--success" : "Label Label--attention"; } function definitionStatusLabel(def) { - if (def?.status === "disabled") return "disabled"; - return def?.compiled === "yes" ? "enabled" : "not compiled"; + if (def?.status === "disabled") return "disabled"; + return def?.compiled === "yes" ? "enabled" : "not compiled"; } function formatDuration(ms) { - if (ms == null) return "—"; - const secs = Math.round(ms / 1000); - if (secs < 60) return `${secs}s`; - return `${Math.floor(secs / 60)}m ${secs % 60}s`; + if (ms == null) return "—"; + const secs = Math.round(ms / 1000); + if (secs < 60) return `${secs}s`; + return `${Math.floor(secs / 60)}m ${secs % 60}s`; } function formatDate(iso) { - if (!iso) return "—"; - const d = new Date(iso); - return isNaN(d.getTime()) ? "—" : d.toLocaleString(); + if (!iso) return "—"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString(); +} + +function formatNumber(value, options = {}) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) return "0"; + return new Intl.NumberFormat("en-US", options).format(numeric); +} + +function formatAIC(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return "0"; + return formatNumber(Math.ceil(numeric)); +} + +function reportWindowById(windowId) { + return reportWindows.find(window => window.id === windowId) ?? reportWindows[1]; +} + +function buildReportMessage(meta, emptyLabel) { + if (!meta?.window) { + return ""; + } + + const fragments = [`Window: ${meta.window.label}`]; + if (meta.logsFetches) { + fragments.push(`${meta.logsFetches} log request${meta.logsFetches === 1 ? "" : "s"}`); + } + if (meta.partial) { + fragments.push("continuation still available"); + } + if (meta.total_runs != null) { + fragments.push(`${meta.total_runs} runs analyzed`); + } + + return fragments.length > 0 ? fragments.join(" · ") : emptyLabel; } Alpine.data("dashboardApp", () => ({ - tabs: dashboardTabs, - activeTab: "definitions", - pageSize: 20, - definitions: [], - runs: [], - experiments: [], - definitionsPaged: paginate([], 1, 20), - runsPaged: paginate([], 1, 20), - experimentsPaged: paginate([], 1, 20), - selectedRun: null, - commandInput: "gh aw status", - commandOutput: "", - flashMessage: "", - flashKind: "success", - loadingDefinitions: true, - loadingRuns: true, - loadingExperiments: true, - errorDefinitions: "", - errorRuns: "", - errorExperiments: "", - - async init() { - await Promise.all([this.fetchDefinitions(), this.fetchRuns(), this.fetchExperiments()]); - }, - - async fetchDefinitions() { - this.loadingDefinitions = true; - this.errorDefinitions = ""; - try { - const resp = await fetch("/api/status"); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); - this.definitions = Array.isArray(data) ? data : []; - this.loadDefinitionPage(1); - } catch (e) { - this.errorDefinitions = `Failed to load workflows: ${e.message}`; - } finally { - this.loadingDefinitions = false; - } - }, - - async fetchRuns() { - this.loadingRuns = true; - this.errorRuns = ""; - try { - const resp = await fetch("/api/runs?count=50"); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); - this.runs = Array.isArray(data) ? data : []; - this.loadRunPage(1); - if (!this.selectedRun && this.runs.length > 0) this.selectedRun = this.runs[0]; - } catch (e) { - this.errorRuns = `Failed to load runs: ${e.message}`; - } finally { - this.loadingRuns = false; - } - }, - - async fetchExperiments() { - this.loadingExperiments = true; - this.errorExperiments = ""; - try { - const resp = await fetch("/api/experiments"); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); - this.experiments = Array.isArray(data) ? data : []; - this.loadExperimentPage(1); - } catch (e) { - this.errorExperiments = `Failed to load experiments: ${e.message}`; - } finally { - this.loadingExperiments = false; - } - }, - - async refresh() { - await fetch("/api/refresh"); - this.flashMessage = "Refreshing…"; - this.flashKind = "success"; - await Promise.all([this.fetchDefinitions(), this.fetchRuns(), this.fetchExperiments()]); - this.flashMessage = "Refreshed."; - setTimeout(() => { this.flashMessage = ""; }, 3000); - }, - - setActiveTab(tab) { - if (this.tabs.some(t => t.id === tab)) this.activeTab = tab; - }, - isActiveTab(tab) { return this.activeTab === tab; }, - tabCount(tab) { - if (tab.counter === "definitions") return this.definitions.length; - if (tab.counter === "runs") return this.runs.length; - if (tab.counter === "experiments") return this.experiments.length; - return 0; - }, - - loadDefinitionPage(page) { - this.definitionsPaged = paginate(this.definitions, page, this.pageSize); - }, - loadRunPage(page) { - this.runsPaged = paginate(this.runs, page, this.pageSize); - }, - loadExperimentPage(page) { - this.experimentsPaged = paginate(this.experiments, page, this.pageSize); - }, - - selectRun(runId) { - this.selectedRun = this.runs.find(r => r.run_id === runId) ?? null; - }, - viewRunDetails(runId) { - this.selectRun(runId); - this.setActiveTab("details"); - }, - - runStatusClass, - runStatusLabel, - definitionStatusClass, - definitionStatusLabel, - formatDuration, - formatDate, - - async runCommand() { - const cmd = this.commandInput.trim(); - this.commandOutput = `$ ${cmd}\n(running…)`; - try { - const resp = await fetch(`/api/run-command?cmd=${encodeURIComponent(cmd)}`); - const result = await resp.json(); - this.commandOutput = `$ ${result.command ?? cmd}\n${result.output ?? ""}`; - } catch (e) { - this.commandOutput = `$ ${cmd}\nError: ${e.message}`; - } - }, - commandQuickFill(value) { - this.commandInput = value; - this.runCommand(); - }, + tabs: dashboardTabs, + reportWindows, + activeTab: "definitions", + selectedWindow: "7d", + logsTimeout: 1, + pageSize: 20, + definitions: [], + runs: [], + usage: [], + experiments: [], + definitionsPaged: paginate([], 1, 20), + runsPaged: paginate([], 1, 20), + usagePaged: paginate([], 1, 20), + experimentsPaged: paginate([], 1, 20), + selectedRun: null, + commandInput: "", + commandOutput: "", + flashMessage: "", + flashKind: "success", + loadingDefinitions: true, + loadingRuns: true, + loadingUsage: true, + loadingExperiments: true, + errorDefinitions: "", + errorRuns: "", + errorUsage: "", + errorExperiments: "", + runsMeta: null, + usageMeta: null, + + async init() { + this.commandInput = this.buildLogsCommand(25); + await Promise.all([this.fetchDefinitions(), this.fetchRuns(), this.fetchUsage(), this.fetchExperiments()]); + }, + + currentWindow() { + return reportWindowById(this.selectedWindow); + }, + + reportWindowClass(windowId) { + return this.selectedWindow === windowId ? "btn btn-sm btn-primary" : "btn btn-sm"; + }, + + async selectReportWindow(windowId) { + if (this.selectedWindow === windowId) return; + this.selectedWindow = windowId; + this.commandInput = this.buildLogsCommand(25); + await Promise.all([this.fetchRuns(), this.fetchUsage()]); + }, + + async fetchDefinitions() { + this.loadingDefinitions = true; + this.errorDefinitions = ""; + try { + const resp = await fetch("/api/status"); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); + this.definitions = Array.isArray(data) ? data : []; + this.loadDefinitionPage(1); + } catch (error) { + this.errorDefinitions = `Failed to load workflows: ${error.message}`; + } finally { + this.loadingDefinitions = false; + } + }, + + async fetchRuns() { + this.loadingRuns = true; + this.errorRuns = ""; + try { + const params = new URLSearchParams({ + count: "100", + window: this.selectedWindow, + timeout: String(this.logsTimeout), + }); + const resp = await fetch(`/api/runs?${params.toString()}`); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); + this.runsMeta = data; + this.runs = Array.isArray(data?.runs) ? data.runs : []; + this.loadRunPage(1); + if (!this.selectedRun || !this.runs.some(run => run.run_id === this.selectedRun?.run_id)) { + this.selectedRun = this.runs[0] ?? null; + } + } catch (error) { + this.errorRuns = `Failed to load runs: ${error.message}`; + this.runsMeta = null; + } finally { + this.loadingRuns = false; + } + }, + + async fetchUsage() { + this.loadingUsage = true; + this.errorUsage = ""; + try { + const params = new URLSearchParams({ + count: "100", + window: this.selectedWindow, + timeout: String(this.logsTimeout), + }); + const resp = await fetch(`/api/usage?${params.toString()}`); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); + this.usageMeta = data; + this.usage = Array.isArray(data?.items) ? data.items : []; + this.loadUsagePage(1); + } catch (error) { + this.errorUsage = `Failed to load usage summary: ${error.message}`; + this.usageMeta = null; + } finally { + this.loadingUsage = false; + } + }, + + async fetchExperiments() { + this.loadingExperiments = true; + this.errorExperiments = ""; + try { + const resp = await fetch("/api/experiments"); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`); + this.experiments = Array.isArray(data) ? data : []; + this.loadExperimentPage(1); + } catch (error) { + this.errorExperiments = `Failed to load experiments: ${error.message}`; + } finally { + this.loadingExperiments = false; + } + }, + + async refresh() { + await fetch("/api/refresh"); + this.flashMessage = "Refreshing…"; + this.flashKind = "success"; + await Promise.all([this.fetchDefinitions(), this.fetchRuns(), this.fetchUsage(), this.fetchExperiments()]); + this.flashMessage = "Refreshed."; + setTimeout(() => { + this.flashMessage = ""; + }, 3000); + }, + + setActiveTab(tab) { + if (this.tabs.some(item => item.id === tab)) this.activeTab = tab; + }, + + isActiveTab(tab) { + return this.activeTab === tab; + }, + + tabCount(tab) { + if (tab.counter === "definitions") return this.definitions.length; + if (tab.counter === "runs") return this.runs.length; + if (tab.counter === "usage") return this.usage.length; + if (tab.counter === "experiments") return this.experiments.length; + return 0; + }, + + loadDefinitionPage(page) { + this.definitionsPaged = paginate(this.definitions, page, this.pageSize); + }, + + loadRunPage(page) { + this.runsPaged = paginate(this.runs, page, this.pageSize); + }, + + loadUsagePage(page) { + this.usagePaged = paginate(this.usage, page, this.pageSize); + }, + + loadExperimentPage(page) { + this.experimentsPaged = paginate(this.experiments, page, this.pageSize); + }, + + selectRun(runId) { + this.selectedRun = this.runs.find(run => run.run_id === runId) ?? null; + }, + + viewRunDetails(runId) { + this.selectRun(runId); + this.setActiveTab("details"); + }, + + buildLogsCommand(count = 25) { + const window = this.currentWindow(); + return `gh aw logs --json -c ${count} --start-date ${window.startDate} --timeout ${this.logsTimeout}`; + }, + + buildLogsSummaryMessage(meta) { + return buildReportMessage(meta, "No logs metadata available."); + }, + + runStatusClass, + runStatusLabel, + definitionStatusClass, + definitionStatusLabel, + formatDuration, + formatDate, + formatAIC, + formatNumber, + + async runCommand() { + const cmd = this.commandInput.trim(); + this.commandOutput = `$ ${cmd}\n(running…)`; + try { + const params = new URLSearchParams({ + cmd, + window: this.selectedWindow, + timeout: String(this.logsTimeout), + }); + const resp = await fetch(`/api/run-command?${params.toString()}`); + const result = await resp.json(); + this.commandOutput = `$ ${result.command ?? cmd}\n${result.output ?? ""}`; + } catch (error) { + this.commandOutput = `$ ${cmd}\nError: ${error.message}`; + } + }, + + commandQuickFill(value) { + this.commandInput = value; + this.runCommand(); + }, })); Alpine.start(); diff --git a/.github/extensions/agentic-workflows-dashboard/web/index.html b/.github/extensions/agentic-workflows-dashboard/web/index.html index 952d783ace2..ac0563734c5 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/index.html +++ b/.github/extensions/agentic-workflows-dashboard/web/index.html @@ -36,6 +36,22 @@
+
+
+
+
Report logs window
+
Runs, usage, and logs commands reuse the same window and timeout.
+
+
+
+ +
+ Timeout m/request +
+
+
@@ -79,6 +95,7 @@

Workflow definitions

Workflow runs

+
@@ -95,6 +112,7 @@

Workflow runs #

Workflow:
Duration:
+
AIC:
@@ -127,6 +145,10 @@

Run details

Duration
+
+
AIC
+
+
Tokens
@@ -153,6 +175,48 @@

Run details

+ +
+
+
+

Usage forecast

+ +
+
+ Loading usage forecast… +
+
+
+
+
Workflow
+
Runs
+
Total AIC
+
AIC / run
+
Daily AIC
+
Monthly forecast
+
Last run
+
+ +
No usage data found for the selected report window.
+
+ +
+
+
@@ -204,11 +268,12 @@

Command panel

- - + +
+
Logs commands automatically reuse the selected report window and timeout unless you override those flags.
diff --git a/.github/extensions/agentic-workflows-dashboard/web/styles.css b/.github/extensions/agentic-workflows-dashboard/web/styles.css index 5b1efc52ef0..49d2bfa4f09 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/styles.css +++ b/.github/extensions/agentic-workflows-dashboard/web/styles.css @@ -19,6 +19,24 @@ .awd-command-output { white-space: pre-wrap; - max-height: 180px; + max-height: 260px; overflow: auto; } + +.awd-usage-header, +.awd-usage-row { + display: grid; + grid-template-columns: minmax(220px, 2fr) repeat(5, minmax(88px, 1fr)) minmax(140px, 1.2fr); + gap: 12px; + align-items: center; +} + +.awd-usage-header { + font-size: 12px; + font-weight: 600; + color: var(--fgColor-muted, #656d76); + background: var(--bgColor-muted, #f6f8fa); + position: sticky; + top: 0; + z-index: 1; +} From 7bceaccb623202300a1c6f8e8906e6faa9f38f2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:56:38 +0000 Subject: [PATCH 2/9] Refine dashboard logs handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agentic-workflows-dashboard/extension.mjs | 105 ++++++++++-------- .../agentic-workflows-dashboard/web/app.js | 2 +- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index 6eba99bcab3..e6fbd0a62d4 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -158,33 +158,20 @@ function mergeRuns(existingRuns, nextRuns) { return Array.from(merged.values()).sort((a, b) => Number(b.run_id ?? 0) - Number(a.run_id ?? 0)); } -async function getLogsData(options = {}) { - const normalized = normalizeLogsOptions(options); - const key = `logs:${JSON.stringify({ - window: normalized.window.id, - count: normalized.count, - timeout: normalized.timeout, - startDate: normalized.startDate, - endDate: normalized.endDate, - beforeRunID: normalized.beforeRunID, - afterRunID: normalized.afterRunID, - workflowName: normalized.workflowName, - engine: normalized.engine, - branch: normalized.branch, - artifacts: normalized.artifacts, - })}`; - const hit = getCached(key); - if (hit) return hit; - - let current = normalized; +async function fetchLogsBatches(initialOptions, initialArgs = null) { + let current = initialOptions; let logsFetches = 0; let runs = []; let continuation = null; let summary = null; + let firstBatch = null; while (current && logsFetches < MAX_LOG_CONTINUATIONS) { - const raw = await runGhAw(buildLogsArgs(current)); + const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); const data = JSON.parse(raw); + if (!firstBatch) { + firstBatch = data; + } runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); continuation = data?.continuation ?? null; summary = data?.summary ?? summary; @@ -194,18 +181,48 @@ async function getLogsData(options = {}) { break; } - current = continuationToLogsOptions(continuation, normalized); + current = continuationToLogsOptions(continuation, initialOptions); } - const result = { + return { + firstBatch, runs, summary, - window: normalized.window, - timeout: normalized.timeout, logsFetches, partial: Boolean(continuation), continuation, }; +} + +async function getLogsData(options = {}) { + const normalized = normalizeLogsOptions(options); + const key = `logs:${JSON.stringify({ + window: normalized.window.id, + count: normalized.count, + timeout: normalized.timeout, + startDate: normalized.startDate, + endDate: normalized.endDate, + beforeRunID: normalized.beforeRunID, + afterRunID: normalized.afterRunID, + workflowName: normalized.workflowName, + engine: normalized.engine, + branch: normalized.branch, + artifacts: normalized.artifacts, + })}`; + const hit = getCached(key); + if (hit) return hit; + + const logsResult = await fetchLogsBatches(normalized); + + const result = { + runs: logsResult.runs, + summary: logsResult.summary, + window: normalized.window, + timeout: normalized.timeout, + logsFetches: logsResult.logsFetches, + partial: logsResult.partial, + continuation: logsResult.continuation, + }; setCached(key, result); return result; } @@ -221,6 +238,7 @@ function toNumber(value) { function buildUsageSummary(runs, window) { const usageByWorkflow = new Map(); + const effectiveDays = Math.max(Number(window?.days ?? 0), 1); for (const run of runs) { const workflowName = String(run?.workflow_name ?? "").trim(); @@ -250,7 +268,7 @@ function buildUsageSummary(runs, window) { return Array.from(usageByWorkflow.values()) .map(entry => { const costPerRun = entry.run_count > 0 ? entry.total_aic / entry.run_count : 0; - const dailyAIC = window.days > 0 ? entry.total_aic / window.days : 0; + const dailyAIC = entry.total_aic / effectiveDays; return { ...entry, cost_per_run: costPerRun, @@ -288,7 +306,15 @@ function parseGhAwArgs(raw) { } function hasFlag(args, longFlag, shortFlag = "") { - return args.some((arg, index) => arg === longFlag || arg.startsWith(`${longFlag}=`) || (shortFlag && (arg === shortFlag || arg.startsWith(`${shortFlag}=`))) || ((arg === longFlag || arg === shortFlag) && index < args.length - 1)); + return args.some((arg, index) => { + if (arg === longFlag || arg.startsWith(`${longFlag}=`)) { + return true; + } + if (shortFlag && (arg === shortFlag || arg.startsWith(`${shortFlag}=`))) { + return true; + } + return (arg === longFlag || (shortFlag && arg === shortFlag)) && index < args.length - 1; + }); } function logsCommandUsesJSON(args) { @@ -317,31 +343,18 @@ async function execCommand(rawCmd, options = {}) { try { if (args[0] === "logs" && logsCommandUsesJSON(args)) { const commandArgs = normalizeLogsCommandArgs(args, options.window, options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES); - const output = await runGhAw(commandArgs); - const firstBatch = JSON.parse(output); - let runs = Array.isArray(firstBatch?.runs) ? firstBatch.runs : []; - let continuation = firstBatch?.continuation ?? null; - let logsFetches = 1; - let current = continuationToLogsOptions(continuation, normalizeLogsOptions({ window: options.window, timeout: options.timeout })); - - while (continuation && current && logsFetches < MAX_LOG_CONTINUATIONS) { - const continuationOutput = await runGhAw(buildLogsArgs(current)); - const continuationBatch = JSON.parse(continuationOutput); - runs = mergeRuns(runs, Array.isArray(continuationBatch?.runs) ? continuationBatch.runs : []); - continuation = continuationBatch?.continuation ?? null; - current = continuationToLogsOptions(continuation, current); - logsFetches += 1; - } + const logsOptions = normalizeLogsOptions({ window: options.window, timeout: options.timeout }); + const logsResult = await fetchLogsBatches(logsOptions, commandArgs); return { command: `gh aw ${commandArgs.join(" ")}`, output: JSON.stringify( { - ...firstBatch, - runs, - partial: Boolean(continuation), - logs_fetches: logsFetches, - continuation, + ...(logsResult.firstBatch ?? {}), + runs: logsResult.runs, + partial: logsResult.partial, + logs_fetches: logsResult.logsFetches, + continuation: logsResult.continuation, }, null, 2 diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index 7e16758bac5..bb4aa483e01 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/app.js +++ b/.github/extensions/agentic-workflows-dashboard/web/app.js @@ -58,7 +58,7 @@ function formatDate(iso) { function formatNumber(value, options = {}) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) return "0"; - return new Intl.NumberFormat("en-US", options).format(numeric); + return new Intl.NumberFormat(undefined, options).format(numeric); } function formatAIC(value) { From ad9190c3a242723ccdaeef4cc50a8076bcc776c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:59:19 +0000 Subject: [PATCH 3/9] Harden dashboard report fetching Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agentic-workflows-dashboard/extension.mjs | 21 ++++++++++++++----- .../agentic-workflows-dashboard/web/app.js | 12 +++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index e6fbd0a62d4..05418fbdb54 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -168,7 +168,12 @@ async function fetchLogsBatches(initialOptions, initialArgs = null) { while (current && logsFetches < MAX_LOG_CONTINUATIONS) { const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); - const data = JSON.parse(raw); + let data; + try { + data = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${error.message}`); + } if (!firstBatch) { firstBatch = data; } @@ -238,7 +243,10 @@ function toNumber(value) { function buildUsageSummary(runs, window) { const usageByWorkflow = new Map(); - const effectiveDays = Math.max(Number(window?.days ?? 0), 1); + const effectiveDays = Number(window?.days ?? 0); + if (!Number.isFinite(effectiveDays) || effectiveDays <= 0) { + throw new Error("Report window is missing a valid positive day count."); + } for (const run of runs) { const workflowName = String(run?.workflow_name ?? "").trim(); @@ -307,13 +315,16 @@ function parseGhAwArgs(raw) { function hasFlag(args, longFlag, shortFlag = "") { return args.some((arg, index) => { - if (arg === longFlag || arg.startsWith(`${longFlag}=`)) { + if (arg.startsWith(`${longFlag}=`)) { + return true; + } + if (shortFlag && arg.startsWith(`${shortFlag}=`)) { return true; } - if (shortFlag && (arg === shortFlag || arg.startsWith(`${shortFlag}=`))) { + if (arg === longFlag || (shortFlag && arg === shortFlag)) { return true; } - return (arg === longFlag || (shortFlag && arg === shortFlag)) && index < args.length - 1; + return false; }); } diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index bb4aa483e01..cbc60991ee5 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/app.js +++ b/.github/extensions/agentic-workflows-dashboard/web/app.js @@ -15,6 +15,7 @@ const reportWindows = [ { id: "7d", label: "7 days", startDate: "-1w" }, { id: "1mo", label: "1 month", startDate: "-1mo" }, ]; +const DEFAULT_LOGS_COMMAND_COUNT = 25; function runStatusClass(run) { const status = run?.status ?? ""; @@ -122,7 +123,7 @@ Alpine.data("dashboardApp", () => ({ usageMeta: null, async init() { - this.commandInput = this.buildLogsCommand(25); + this.commandInput = this.buildLogsCommand(); await Promise.all([this.fetchDefinitions(), this.fetchRuns(), this.fetchUsage(), this.fetchExperiments()]); }, @@ -137,7 +138,7 @@ Alpine.data("dashboardApp", () => ({ async selectReportWindow(windowId) { if (this.selectedWindow === windowId) return; this.selectedWindow = windowId; - this.commandInput = this.buildLogsCommand(25); + this.commandInput = this.buildLogsCommand(); await Promise.all([this.fetchRuns(), this.fetchUsage()]); }, @@ -161,6 +162,7 @@ Alpine.data("dashboardApp", () => ({ this.loadingRuns = true; this.errorRuns = ""; try { + const previousRunId = this.selectedRun?.run_id ?? null; const params = new URLSearchParams({ count: "100", window: this.selectedWindow, @@ -172,9 +174,7 @@ Alpine.data("dashboardApp", () => ({ this.runsMeta = data; this.runs = Array.isArray(data?.runs) ? data.runs : []; this.loadRunPage(1); - if (!this.selectedRun || !this.runs.some(run => run.run_id === this.selectedRun?.run_id)) { - this.selectedRun = this.runs[0] ?? null; - } + this.selectedRun = this.runs.find(run => run.run_id === previousRunId) ?? this.runs[0] ?? null; } catch (error) { this.errorRuns = `Failed to load runs: ${error.message}`; this.runsMeta = null; @@ -274,7 +274,7 @@ Alpine.data("dashboardApp", () => ({ this.setActiveTab("details"); }, - buildLogsCommand(count = 25) { + buildLogsCommand(count = DEFAULT_LOGS_COMMAND_COUNT) { const window = this.currentWindow(); return `gh aw logs --json -c ${count} --start-date ${window.startDate} --timeout ${this.logsTimeout}`; }, From b303dbcc7420a7a173a278450df57006411a0efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:01:44 +0000 Subject: [PATCH 4/9] Polish dashboard review fixes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/extensions/agentic-workflows-dashboard/extension.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index 05418fbdb54..fd98e5eb8fe 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -245,7 +245,7 @@ function buildUsageSummary(runs, window) { const usageByWorkflow = new Map(); const effectiveDays = Number(window?.days ?? 0); if (!Number.isFinite(effectiveDays) || effectiveDays <= 0) { - throw new Error("Report window is missing a valid positive day count."); + throw new Error(`Report window '${window?.id ?? "unknown"}' is missing a valid positive day count.`); } for (const run of runs) { @@ -314,7 +314,7 @@ function parseGhAwArgs(raw) { } function hasFlag(args, longFlag, shortFlag = "") { - return args.some((arg, index) => { + return args.some(arg => { if (arg.startsWith(`${longFlag}=`)) { return true; } From 2b59e003f95e685dfecbe3332b6622483ab17bf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:04:06 +0000 Subject: [PATCH 5/9] Tidy dashboard report labels Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/extensions/agentic-workflows-dashboard/extension.mjs | 2 +- .github/extensions/agentic-workflows-dashboard/web/app.js | 2 +- .github/extensions/agentic-workflows-dashboard/web/index.html | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index fd98e5eb8fe..ec7e338ce60 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -245,7 +245,7 @@ function buildUsageSummary(runs, window) { const usageByWorkflow = new Map(); const effectiveDays = Number(window?.days ?? 0); if (!Number.isFinite(effectiveDays) || effectiveDays <= 0) { - throw new Error(`Report window '${window?.id ?? "unknown"}' is missing a valid positive day count.`); + throw new Error(`report window '${window?.id ?? "unknown"}' is missing a valid positive day count.`); } for (const run of runs) { diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index cbc60991ee5..bd4d7455c90 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/app.js +++ b/.github/extensions/agentic-workflows-dashboard/web/app.js @@ -279,7 +279,7 @@ Alpine.data("dashboardApp", () => ({ return `gh aw logs --json -c ${count} --start-date ${window.startDate} --timeout ${this.logsTimeout}`; }, - buildLogsSummaryMessage(meta) { + buildReportSummaryMessage(meta) { return buildReportMessage(meta, "No logs metadata available."); }, diff --git a/.github/extensions/agentic-workflows-dashboard/web/index.html b/.github/extensions/agentic-workflows-dashboard/web/index.html index ac0563734c5..f52d0afc7b9 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/index.html +++ b/.github/extensions/agentic-workflows-dashboard/web/index.html @@ -95,7 +95,7 @@

Workflow definitions

Workflow runs

- +
@@ -180,7 +180,7 @@

Run details

Usage forecast

- +
Loading usage forecast… From 12d2cd24612a1ccd891a0231c6bc91f8adb1f908 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:25:36 +0000 Subject: [PATCH 6/9] Use forecast command for dashboard usage costs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agentic-workflows-dashboard/extension.mjs | 90 ++++++-------- .../test/usage-forecast.test.ts | 99 ++++++++++++++++ .../usage-forecast.mjs | 110 ++++++++++++++++++ 3 files changed, 244 insertions(+), 55 deletions(-) create mode 100644 .github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts create mode 100644 .github/extensions/agentic-workflows-dashboard/usage-forecast.mjs diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index ec7e338ce60..0b0129d1868 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow } from "./usage-forecast.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const servers = new Map(); const cache = new Map(); // key → { data, expiresAt } @@ -236,72 +238,50 @@ async function getRuns(options = {}) { return getLogsData(options); } -function toNumber(value) { - const numeric = Number(value ?? 0); - return Number.isFinite(numeric) ? numeric : 0; -} - -function buildUsageSummary(runs, window) { - const usageByWorkflow = new Map(); - const effectiveDays = Number(window?.days ?? 0); - if (!Number.isFinite(effectiveDays) || effectiveDays <= 0) { - throw new Error(`report window '${window?.id ?? "unknown"}' is missing a valid positive day count.`); +async function getForecastData(workflowIDs, window, timeout) { + if (!Array.isArray(workflowIDs) || workflowIDs.length === 0) { + return []; } - for (const run of runs) { - const workflowName = String(run?.workflow_name ?? "").trim(); - if (!workflowName) continue; - - const aic = toNumber(run?.aic); - const entry = usageByWorkflow.get(workflowName) ?? { - workflow_name: workflowName, - run_count: 0, - total_aic: 0, - cost_per_run: 0, - daily_aic: 0, - monthly_forecast_aic: 0, - last_run_at: "", - }; - - entry.run_count += 1; - entry.total_aic += aic; - const createdAt = typeof run?.created_at === "string" ? run.created_at : ""; - if (createdAt && (!entry.last_run_at || createdAt > entry.last_run_at)) { - entry.last_run_at = createdAt; - } - - usageByWorkflow.set(workflowName, entry); + const args = ["forecast", "--json", "--period", "month", "--days", String(forecastDaysForWindow(window)), "--timeout", String(timeout), ...workflowIDs]; + const raw = await runGhAw(args); + let data; + try { + data = JSON.parse(raw); + } catch (error) { + const snippet = String(raw ?? "") + .replace(/\s+/g, " ") + .slice(0, 200); + throw new Error(`Failed to parse forecast output: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); } - - return Array.from(usageByWorkflow.values()) - .map(entry => { - const costPerRun = entry.run_count > 0 ? entry.total_aic / entry.run_count : 0; - const dailyAIC = entry.total_aic / effectiveDays; - return { - ...entry, - cost_per_run: costPerRun, - daily_aic: dailyAIC, - monthly_forecast_aic: dailyAIC * 30, - }; - }) - .sort((a, b) => { - const dailyDelta = b.daily_aic - a.daily_aic; - if (dailyDelta !== 0) return dailyDelta; - return b.cost_per_run - a.cost_per_run; - }); + return Array.isArray(data?.workflows) ? data.workflows : []; } async function getUsage(options = {}) { - const logsData = await getLogsData(options); - return { - items: buildUsageSummary(logsData.runs, logsData.window), + const normalized = normalizeLogsOptions(options); + const key = `usage:${JSON.stringify({ + window: normalized.window.id, + count: normalized.count, + timeout: normalized.timeout, + })}`; + const hit = getCached(key); + if (hit) return hit; + const logsData = await getLogsData(normalized); + const usageItems = buildUsageSummary(logsData.runs, logsData.window); + const workflowIDs = usageItems.map(item => item.workflow_id).filter(Boolean); + const forecastWorkflows = await getForecastData(workflowIDs, logsData.window, logsData.timeout); + const result = { + items: applyForecastToUsageSummary(usageItems, forecastWorkflows), window: logsData.window, timeout: logsData.timeout, logsFetches: logsData.logsFetches, partial: logsData.partial, continuation: logsData.continuation, total_runs: logsData.runs.length, + forecast_history_days: forecastDaysForWindow(logsData.window), }; + setCached(key, result); + return result; } // --------------------------------------------------------------------------- @@ -494,7 +474,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman **Canvas actions available to the agent:** - \`listDefinitions\` — calls \`gh aw status --json\`, returns paged results - \`listRuns\` — calls \`gh aw logs --json\` with a selected report window, timeout, and continuation handling -- \`listUsage\` — aggregates workflow AIC usage, daily burn, and monthly forecast from the same logs window +- \`listUsage\` — aggregates workflow AIC usage from logs and fills monthly forecast via \`gh aw forecast --json\` - \`listExperiments\` — calls \`gh aw experiments list --json\`, returns paged results - \`getRun\` — looks up a single run by \`run_id\` - \`runCommand\` — executes any \`gh aw \` and returns stdout @@ -553,7 +533,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman }, { name: "listUsage", - description: "Aggregate workflow AIC usage, daily burn, and monthly forecast from gh aw logs.", + description: "Aggregate workflow AIC usage from gh aw logs and monthly forecast costs from gh aw forecast.", inputSchema: { type: "object", properties: { diff --git a/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts b/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts new file mode 100644 index 00000000000..b96b1b8f185 --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow, getForecastMonthlyAIC, normalizeWorkflowID } from "../usage-forecast.mjs"; + +describe("usage forecast helpers", () => { + it("normalizes workflow ids from workflow paths", () => { + expect(normalizeWorkflowID(".github/workflows/ci-doctor.lock.yml")).toBe("ci-doctor"); + expect(normalizeWorkflowID("daily-planner.md")).toBe("daily-planner"); + }); + + it("maps dashboard windows onto supported forecast history windows", () => { + expect(forecastDaysForWindow({ id: "3d" })).toBe(7); + expect(forecastDaysForWindow({ id: "7d" })).toBe(7); + expect(forecastDaysForWindow({ id: "1mo" })).toBe(30); + }); + + it("prefers monte carlo p50 monthly forecast when available", () => { + expect(getForecastMonthlyAIC({ monthly_monte_carlo: { p50_projected_aic: 123.4 }, monthly_projected_aic: 100 })).toBe(123.4); + expect(getForecastMonthlyAIC({ monthly_projected_aic: 98.2 })).toBe(98.2); + }); + + it("merges logs usage with forecast command output by workflow id", () => { + const summary = applyForecastToUsageSummary( + buildUsageSummary( + [ + { + workflow_name: "CI Doctor", + workflow_path: ".github/workflows/ci-doctor.lock.yml", + aic: 10, + created_at: "2026-06-28T12:00:00Z", + }, + { + workflow_name: "CI Doctor", + workflow_path: ".github/workflows/ci-doctor.lock.yml", + aic: 5, + created_at: "2026-06-29T12:00:00Z", + }, + ], + { id: "7d", days: 7 } + ), + [ + { + workflow_id: "ci-doctor", + monthly_monte_carlo: { p50_projected_aic: 77.7 }, + }, + ] + ); + + expect(summary).toHaveLength(1); + expect(summary[0]).toMatchObject({ + workflow_id: "ci-doctor", + workflow_name: "CI Doctor", + run_count: 2, + total_aic: 15, + cost_per_run: 7.5, + daily_aic: 15 / 7, + monthly_forecast_aic: 77.7, + last_run_at: "2026-06-29T12:00:00Z", + }); + }); + + it("can build summary with forecast data in a single call", () => { + const summary = buildUsageSummary( + [ + { + workflow_name: "CI Doctor", + workflow_path: ".github/workflows/ci-doctor.lock.yml", + aic: 10, + created_at: "2026-06-28T12:00:00Z", + }, + { + workflow_name: "CI Doctor", + workflow_path: ".github/workflows/ci-doctor.lock.yml", + aic: 5, + created_at: "2026-06-29T12:00:00Z", + }, + ], + { id: "7d", days: 7 }, + [ + { + workflow_id: "ci-doctor", + monthly_monte_carlo: { p50_projected_aic: 77.7 }, + }, + ] + ); + + expect(summary).toHaveLength(1); + expect(summary[0]).toMatchObject({ + workflow_id: "ci-doctor", + workflow_name: "CI Doctor", + run_count: 2, + total_aic: 15, + cost_per_run: 7.5, + daily_aic: 15 / 7, + monthly_forecast_aic: 77.7, + last_run_at: "2026-06-29T12:00:00Z", + }); + }); +}); diff --git a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs new file mode 100644 index 00000000000..c6658a9dd88 --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs @@ -0,0 +1,110 @@ +import { basename } from "node:path"; + +export function toNumber(value) { + const numeric = Number(value ?? 0); + return Number.isFinite(numeric) ? numeric : 0; +} + +export function normalizeWorkflowID(value) { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + let name = basename(raw); + const lowerName = name.toLowerCase(); + for (const suffix of [".lock.yml", ".yml", ".yaml", ".md"]) { + if (lowerName.endsWith(suffix)) { + name = name.slice(0, -suffix.length); + break; + } + } + return name.trim(); +} + +export function forecastDaysForWindow(window) { + return window?.id === "1mo" ? 30 : 7; +} + +export function getForecastMonthlyAIC(forecast) { + if (!forecast || typeof forecast !== "object") return 0; + const monteCarloP50 = toNumber(forecast.monthly_monte_carlo?.p50_projected_aic); + if (monteCarloP50 > 0) return monteCarloP50; + return toNumber(forecast.monthly_projected_aic); +} + +export function applyForecastToUsageSummary(items, forecastWorkflows = []) { + // Forecast results identify workflows by workflow_id; workflow_path is accepted as a + // fallback so older or alternate JSON payloads can still be matched safely. + const forecastByWorkflow = new Map( + forecastWorkflows + .map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]) + .filter(([workflowID]) => Boolean(workflowID)) + ); + + return items.map(item => ({ + ...item, + monthly_forecast_aic: forecastByWorkflow.get(item.workflow_id) ?? 0, + })); +} + +export function buildUsageSummary(runs, window, forecastWorkflows = []) { + const usageByWorkflow = new Map(); + const effectiveDays = Number(window?.days ?? 0); + if (!Number.isFinite(effectiveDays) || effectiveDays <= 0) { + throw new Error(`report window '${window?.id ?? "unknown"}' is missing a valid positive day count.`); + } + + for (const run of runs) { + const workflowPath = typeof run?.workflow_path === "string" ? run.workflow_path.trim() : ""; + const workflowID = normalizeWorkflowID(workflowPath || run?.workflow_name); + if (!workflowID) continue; + + const workflowName = String(run?.workflow_name ?? workflowID).trim() || workflowID; + const aic = toNumber(run?.aic); + const entry = usageByWorkflow.get(workflowID) ?? { + workflow_id: workflowID, + workflow_name: workflowName, + workflow_path: workflowPath, + run_count: 0, + total_aic: 0, + cost_per_run: 0, + daily_aic: 0, + monthly_forecast_aic: 0, + last_run_at: "", + }; + + entry.run_count += 1; + entry.total_aic += aic; + if (!entry.workflow_path && workflowPath) { + entry.workflow_path = workflowPath; + } + if (!entry.workflow_name && workflowName) { + entry.workflow_name = workflowName; + } + + const createdAt = typeof run?.created_at === "string" ? run.created_at : ""; + if (createdAt && (!entry.last_run_at || createdAt > entry.last_run_at)) { + entry.last_run_at = createdAt; + } + + usageByWorkflow.set(workflowID, entry); + } + + const items = Array.from(usageByWorkflow.values()) + .map(entry => { + const costPerRun = entry.run_count > 0 ? entry.total_aic / entry.run_count : 0; + const dailyAIC = entry.total_aic / effectiveDays; + return { + ...entry, + cost_per_run: costPerRun, + daily_aic: dailyAIC, + monthly_forecast_aic: 0, + }; + }) + .sort((a, b) => { + const dailyDelta = b.daily_aic - a.daily_aic; + if (dailyDelta !== 0) return dailyDelta; + return b.cost_per_run - a.cost_per_run; + }); + + return applyForecastToUsageSummary(items, forecastWorkflows); +} From f4aa159aad30d1a320f3090025c40258e6468800 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:36:33 +0000 Subject: [PATCH 7/9] Refactor dashboard extension helpers Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../dashboard-cli.mjs | 36 ++ .../dashboard-config.mjs | 14 + .../dashboard-data.mjs | 204 ++++++++++ .../dashboard-logs.mjs | 209 ++++++++++ .../agentic-workflows-dashboard/extension.mjs | 383 +----------------- .../agentic-workflows-dashboard/package.json | 2 +- .../test/dashboard-data.test.ts | 58 +++ .../test/dashboard-logs.test.ts | 44 ++ .../usage-forecast.mjs | 6 +- 9 files changed, 586 insertions(+), 370 deletions(-) create mode 100644 .github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs create mode 100644 .github/extensions/agentic-workflows-dashboard/dashboard-config.mjs create mode 100644 .github/extensions/agentic-workflows-dashboard/dashboard-data.mjs create mode 100644 .github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs create mode 100644 .github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts create mode 100644 .github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs new file mode 100644 index 00000000000..8130307033f --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs @@ -0,0 +1,36 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access } from "node:fs/promises"; +import { join } from "node:path"; + +function execp(bin, args, cwd) { + return new Promise((resolve, reject) => { + execFile( + bin, + args, + { + cwd, + env: { ...process.env, NO_COLOR: "1", GH_NO_UPDATE_NOTIFIER: "1" }, + maxBuffer: 10 * 1024 * 1024, + }, + (err, stdout, stderr) => { + if (err) reject(Object.assign(err, { stderr: stderr ?? "" })); + else resolve(stdout); + } + ); + }); +} + +export function createGhAwRunner({ getWorkspacePath }) { + return async function runGhAw(args) { + const cwd = getWorkspacePath(); + const isWin = process.platform === "win32"; + const devBin = join(cwd, isWin ? "gh-aw.exe" : "gh-aw"); + try { + await access(devBin, fsConstants.X_OK); + return await execp(devBin, args, cwd); + } catch { + return await execp("gh", ["aw", ...args], cwd); + } + }; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs new file mode 100644 index 00000000000..f4bcdceedef --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs @@ -0,0 +1,14 @@ +export const CACHE_TTL_MS = 60_000; +export const DEFAULT_LOG_TIMEOUT_MINUTES = 1; +export const DEFAULT_REPORT_WINDOW_ID = "7d"; +export const DEFAULT_RUN_COUNT = 100; +export const MAX_LOG_CONTINUATIONS = 6; +export const REPORT_WINDOWS = { + "3d": { id: "3d", label: "3 days", startDate: "-3d", days: 3 }, + "7d": { id: "7d", label: "7 days", startDate: "-1w", days: 7 }, + "1mo": { id: "1mo", label: "1 month", startDate: "-1mo", days: 30 }, +}; + +export function getReportWindow(windowId) { + return REPORT_WINDOWS[windowId] ?? REPORT_WINDOWS[DEFAULT_REPORT_WINDOW_ID]; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-data.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-data.mjs new file mode 100644 index 00000000000..8eb1791098a --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-data.mjs @@ -0,0 +1,204 @@ +import { CACHE_TTL_MS, DEFAULT_LOG_TIMEOUT_MINUTES, MAX_LOG_CONTINUATIONS } from "./dashboard-config.mjs"; +import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, logsCommandUsesJSON, mergeRuns, normalizeLogsCommandArgs, normalizeLogsOptions, parseGhAwArgs } from "./dashboard-logs.mjs"; +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow } from "./usage-forecast.mjs"; + +export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) { + const cache = new Map(); + + function getCached(key) { + const entry = cache.get(key); + return entry && Date.now() < entry.expiresAt ? entry.data : null; + } + + function setCached(key, data) { + cache.set(key, { data, expiresAt: Date.now() + cacheTTL }); + } + + async function getDefinitions() { + const hit = getCached("definitions"); + if (hit) return hit; + const raw = await runGhAw(["status", "--json"]); + const data = JSON.parse(raw); + setCached("definitions", data); + return data; + } + + async function getExperiments() { + const hit = getCached("experiments"); + if (hit) return hit; + const raw = await runGhAw(["experiments", "list", "--json"]); + const data = JSON.parse(raw); + const experiments = Array.isArray(data) ? data : []; + setCached("experiments", experiments); + return experiments; + } + + async function fetchLogsBatches(initialOptions, initialArgs = null) { + let current = initialOptions; + let logsFetches = 0; + let runs = []; + let continuation = null; + let summary = null; + let firstBatch = null; + + while (current && logsFetches < MAX_LOG_CONTINUATIONS) { + const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); + let data; + try { + data = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${error.message}`); + } + if (!firstBatch) { + firstBatch = data; + } + runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); + continuation = data?.continuation ?? null; + summary = data?.summary ?? summary; + logsFetches += 1; + + if (!continuation) { + break; + } + + current = continuationToLogsOptions(continuation, current); + } + + return { + firstBatch, + runs, + summary, + logsFetches, + partial: Boolean(continuation), + continuation, + }; + } + + async function getLogsData(options = {}) { + const normalized = normalizeLogsOptions(options); + const key = `logs:${JSON.stringify({ + window: normalized.window.id, + count: normalized.count, + timeout: normalized.timeout, + startDate: normalized.startDate, + endDate: normalized.endDate, + beforeRunID: normalized.beforeRunID, + afterRunID: normalized.afterRunID, + workflowName: normalized.workflowName, + engine: normalized.engine, + branch: normalized.branch, + artifacts: normalized.artifacts, + })}`; + const hit = getCached(key); + if (hit) return hit; + + const logsResult = await fetchLogsBatches(normalized); + + const result = { + runs: logsResult.runs, + summary: logsResult.summary, + window: normalized.window, + timeout: normalized.timeout, + logsFetches: logsResult.logsFetches, + partial: logsResult.partial, + continuation: logsResult.continuation, + }; + setCached(key, result); + return result; + } + + async function getForecastData(workflowIDs, window, timeout) { + if (!Array.isArray(workflowIDs) || workflowIDs.length === 0) { + return []; + } + + const args = ["forecast", "--json", "--period", "month", "--days", String(forecastDaysForWindow(window)), "--timeout", String(timeout), ...workflowIDs]; + const raw = await runGhAw(args); + let data; + try { + data = JSON.parse(raw); + } catch (error) { + const snippet = String(raw ?? "") + .replace(/\s+/g, " ") + .slice(0, 200); + throw new Error(`Failed to parse forecast output: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); + } + return Array.isArray(data?.workflows) ? data.workflows : []; + } + + async function getRuns(options = {}) { + return getLogsData(options); + } + + async function getUsage(options = {}) { + const normalized = normalizeLogsOptions(options); + const key = `usage:${JSON.stringify({ + window: normalized.window.id, + count: normalized.count, + timeout: normalized.timeout, + })}`; + const hit = getCached(key); + if (hit) return hit; + + const logsData = await getLogsData(normalized); + const usageItems = buildUsageSummary(logsData.runs, logsData.window); + const workflowIDs = usageItems.map(item => item.workflow_id).filter(Boolean); + const forecastWorkflows = await getForecastData(workflowIDs, logsData.window, logsData.timeout); + const result = { + items: applyForecastToUsageSummary(usageItems, forecastWorkflows), + window: logsData.window, + timeout: logsData.timeout, + logsFetches: logsData.logsFetches, + partial: logsData.partial, + continuation: logsData.continuation, + total_runs: logsData.runs.length, + forecast_history_days: forecastDaysForWindow(logsData.window), + }; + setCached(key, result); + return result; + } + + async function execCommand(rawCmd, options = {}) { + const args = parseGhAwArgs(rawCmd); + if (!args) { + return { command: rawCmd, output: "Only 'gh aw ' commands are supported.", error: true }; + } + + try { + if (args[0] === "logs" && logsCommandUsesJSON(args)) { + const commandArgs = normalizeLogsCommandArgs(args, options.window, options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES); + const logsOptions = logsArgsToOptions(commandArgs, { window: options.window, timeout: options.timeout }); + const logsResult = await fetchLogsBatches(logsOptions, commandArgs); + + return { + command: `gh aw ${commandArgs.join(" ")}`, + output: JSON.stringify( + { + ...(logsResult.firstBatch ?? {}), + runs: logsResult.runs, + partial: logsResult.partial, + logs_fetches: logsResult.logsFetches, + continuation: logsResult.continuation, + }, + null, + 2 + ), + }; + } + + const output = await runGhAw(args); + return { command: rawCmd, output }; + } catch (err) { + return { command: rawCmd, output: err.stderr || err.message, error: true }; + } + } + + return { + clearCache: () => cache.clear(), + execCommand, + getDefinitions, + getExperiments, + getRuns, + getUsage, + }; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs new file mode 100644 index 00000000000..bc85cd09aaf --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs @@ -0,0 +1,209 @@ +import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT, getReportWindow } from "./dashboard-config.mjs"; + +function parsePositiveInt(value, fallback) { + const numeric = Number.parseInt(String(value ?? fallback), 10); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; +} + +function readFlagValue(args, index, arg) { + const equalsIndex = arg.indexOf("="); + if (equalsIndex >= 0) { + return { value: arg.slice(equalsIndex + 1), nextIndex: index }; + } + return { value: args[index + 1] ?? "", nextIndex: index + 1 }; +} + +export function normalizeLogsOptions(options = {}) { + const window = getReportWindow(options.window); + const artifacts = Array.isArray(options.artifacts) && options.artifacts.length > 0 ? options.artifacts : ["usage"]; + + return { + window, + count: parsePositiveInt(options.count, DEFAULT_RUN_COUNT), + timeout: parsePositiveInt(options.timeout, DEFAULT_LOG_TIMEOUT_MINUTES), + startDate: typeof options.startDate === "string" && options.startDate.trim() ? options.startDate.trim() : window.startDate, + endDate: typeof options.endDate === "string" && options.endDate.trim() ? options.endDate.trim() : "", + beforeRunID: Number.isFinite(Number(options.beforeRunID)) && Number(options.beforeRunID) > 0 ? Number(options.beforeRunID) : 0, + afterRunID: Number.isFinite(Number(options.afterRunID)) && Number(options.afterRunID) > 0 ? Number(options.afterRunID) : 0, + workflowName: typeof options.workflowName === "string" ? options.workflowName.trim() : "", + engine: typeof options.engine === "string" ? options.engine.trim() : "", + branch: typeof options.branch === "string" ? options.branch.trim() : "", + artifacts, + }; +} + +export function buildLogsArgs(options) { + const args = ["logs", "--json", "-c", String(options.count), "--timeout", String(options.timeout)]; + + if (options.workflowName) args.push(options.workflowName); + if (options.startDate) args.push("--start-date", options.startDate); + if (options.endDate) args.push("--end-date", options.endDate); + if (options.engine) args.push("--engine", options.engine); + if (options.branch) args.push("--ref", options.branch); + if (options.beforeRunID > 0) args.push("--before-run-id", String(options.beforeRunID)); + if (options.afterRunID > 0) args.push("--after-run-id", String(options.afterRunID)); + if (options.artifacts.length > 0) args.push("--artifacts", options.artifacts.join(",")); + + return args; +} + +export function continuationToLogsOptions(continuation, fallback) { + if (!continuation) return null; + + return normalizeLogsOptions({ + window: fallback.window.id, + workflowName: continuation.workflow_name || fallback.workflowName, + count: continuation.count || fallback.count, + startDate: continuation.start_date || fallback.startDate, + endDate: continuation.end_date || fallback.endDate, + engine: continuation.engine || fallback.engine, + branch: continuation.branch || fallback.branch, + afterRunID: continuation.after_run_id || fallback.afterRunID, + beforeRunID: continuation.before_run_id || fallback.beforeRunID, + timeout: continuation.timeout || fallback.timeout, + artifacts: fallback.artifacts, + }); +} + +export function mergeRuns(existingRuns, nextRuns) { + const merged = new Map(existingRuns.map(run => [run.run_id, run])); + for (const run of nextRuns) { + if (run?.run_id != null) { + merged.set(run.run_id, run); + } + } + return Array.from(merged.values()).sort((a, b) => Number(b.run_id ?? 0) - Number(a.run_id ?? 0)); +} + +export function parseGhAwArgs(raw) { + const match = raw.trim().match(/^(?:gh\s+aw\s+)(.+)$/); + return match ? match[1].trim().split(/\s+/) : null; +} + +export function hasFlag(args, longFlag, shortFlag = "") { + return args.some(arg => { + if (arg.startsWith(`${longFlag}=`)) { + return true; + } + if (shortFlag && arg.startsWith(`${shortFlag}=`)) { + return true; + } + return arg === longFlag || (shortFlag && arg === shortFlag); + }); +} + +export function logsCommandUsesJSON(args) { + return hasFlag(args, "--json", "-j"); +} + +export function normalizeLogsCommandArgs(args, windowId, timeoutMinutes) { + const nextArgs = [...args]; + if (!hasFlag(nextArgs, "--start-date") && !hasFlag(nextArgs, "--end-date") && !hasFlag(nextArgs, "--after-run-id") && !hasFlag(nextArgs, "--before-run-id")) { + nextArgs.push("--start-date", getReportWindow(windowId).startDate); + } + if (!hasFlag(nextArgs, "--timeout")) { + nextArgs.push("--timeout", String(timeoutMinutes)); + } + if (!hasFlag(nextArgs, "--artifacts")) { + nextArgs.push("--artifacts", "usage"); + } + return nextArgs; +} + +export function logsArgsToOptions(args, fallback = {}) { + const options = { + window: typeof fallback.window === "string" ? fallback.window : fallback.window?.id, + count: fallback.count, + timeout: fallback.timeout, + startDate: fallback.startDate, + endDate: fallback.endDate, + beforeRunID: fallback.beforeRunID, + afterRunID: fallback.afterRunID, + workflowName: fallback.workflowName, + engine: fallback.engine, + branch: fallback.branch, + artifacts: fallback.artifacts, + }; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + + if (!arg.startsWith("-")) { + if (!options.workflowName) { + options.workflowName = arg; + } + continue; + } + + if (arg === "--json" || arg === "-j") { + continue; + } + + if (arg === "-c" || arg.startsWith("-c=") || arg === "--count" || arg.startsWith("--count=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.count = value; + index = nextIndex; + continue; + } + + if (arg === "--timeout" || arg.startsWith("--timeout=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.timeout = value; + index = nextIndex; + continue; + } + + if (arg === "--start-date" || arg.startsWith("--start-date=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.startDate = value; + index = nextIndex; + continue; + } + + if (arg === "--end-date" || arg.startsWith("--end-date=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.endDate = value; + index = nextIndex; + continue; + } + + if (arg === "--before-run-id" || arg.startsWith("--before-run-id=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.beforeRunID = value; + index = nextIndex; + continue; + } + + if (arg === "--after-run-id" || arg.startsWith("--after-run-id=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.afterRunID = value; + index = nextIndex; + continue; + } + + if (arg === "--engine" || arg.startsWith("--engine=") || arg === "-e" || arg.startsWith("-e=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.engine = value; + index = nextIndex; + continue; + } + + if (arg === "--ref" || arg.startsWith("--ref=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.branch = value; + index = nextIndex; + continue; + } + + if (arg === "--artifacts" || arg.startsWith("--artifacts=")) { + const { value, nextIndex } = readFlagValue(args, index, arg); + options.artifacts = value + .split(",") + .map(item => item.trim()) + .filter(Boolean); + index = nextIndex; + } + } + + return normalizeLogsOptions(options); +} diff --git a/.github/extensions/agentic-workflows-dashboard/extension.mjs b/.github/extensions/agentic-workflows-dashboard/extension.mjs index 0b0129d1868..c6f522ee501 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -1,364 +1,19 @@ import { createServer } from "node:http"; -import { execFile } from "node:child_process"; -import { access, readFile } from "node:fs/promises"; -import { constants as fsConstants } from "node:fs"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; -import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow } from "./usage-forecast.mjs"; +import { createGhAwRunner } from "./dashboard-cli.mjs"; +import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT } from "./dashboard-config.mjs"; +import { createDashboardDataAccess } from "./dashboard-data.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const servers = new Map(); -const cache = new Map(); // key → { data, expiresAt } -const CACHE_TTL_MS = 60_000; -const DEFAULT_LOG_TIMEOUT_MINUTES = 1; -const DEFAULT_RUN_COUNT = 100; -const MAX_LOG_CONTINUATIONS = 6; -const REPORT_WINDOWS = { - "3d": { id: "3d", label: "3 days", startDate: "-3d", days: 3 }, - "7d": { id: "7d", label: "7 days", startDate: "-1w", days: 7 }, - "1mo": { id: "1mo", label: "1 month", startDate: "-1mo", days: 30 }, -}; let workspacePath = process.cwd(); - -// --------------------------------------------------------------------------- -// CLI helpers -// --------------------------------------------------------------------------- - -function execp(bin, args, cwd) { - return new Promise((resolve, reject) => { - execFile( - bin, - args, - { - cwd, - env: { ...process.env, NO_COLOR: "1", GH_NO_UPDATE_NOTIFIER: "1" }, - maxBuffer: 10 * 1024 * 1024, - }, - (err, stdout, stderr) => { - if (err) reject(Object.assign(err, { stderr: stderr ?? "" })); - else resolve(stdout); - } - ); - }); -} - -async function runGhAw(args) { - const cwd = workspacePath; - const isWin = process.platform === "win32"; - const devBin = join(cwd, isWin ? "gh-aw.exe" : "gh-aw"); - try { - await access(devBin, fsConstants.X_OK); - return await execp(devBin, args, cwd); - } catch { - return await execp("gh", ["aw", ...args], cwd); - } -} - -// --------------------------------------------------------------------------- -// Cache -// --------------------------------------------------------------------------- - -function getCached(key) { - const entry = cache.get(key); - return entry && Date.now() < entry.expiresAt ? entry.data : null; -} -function setCached(key, data) { - cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); -} - -// --------------------------------------------------------------------------- -// Data fetchers — both call the CLI, never Go code -// --------------------------------------------------------------------------- - -async function getDefinitions() { - const hit = getCached("definitions"); - if (hit) return hit; - const raw = await runGhAw(["status", "--json"]); - const data = JSON.parse(raw); - setCached("definitions", data); - return data; -} - -async function getExperiments() { - const hit = getCached("experiments"); - if (hit) return hit; - const raw = await runGhAw(["experiments", "list", "--json"]); - const data = JSON.parse(raw); - const experiments = Array.isArray(data) ? data : []; - setCached("experiments", experiments); - return experiments; -} - -function getReportWindow(windowId) { - return REPORT_WINDOWS[windowId] ?? REPORT_WINDOWS["7d"]; -} - -function normalizeLogsOptions(options = {}) { - const window = getReportWindow(options.window); - const count = Number.parseInt(String(options.count ?? DEFAULT_RUN_COUNT), 10); - const timeout = Number.parseInt(String(options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), 10); - const artifacts = Array.isArray(options.artifacts) && options.artifacts.length > 0 ? options.artifacts : ["usage"]; - - return { - window, - count: Number.isFinite(count) && count > 0 ? count : DEFAULT_RUN_COUNT, - timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_LOG_TIMEOUT_MINUTES, - startDate: typeof options.startDate === "string" && options.startDate.trim() ? options.startDate.trim() : window.startDate, - endDate: typeof options.endDate === "string" && options.endDate.trim() ? options.endDate.trim() : "", - beforeRunID: Number.isFinite(Number(options.beforeRunID)) && Number(options.beforeRunID) > 0 ? Number(options.beforeRunID) : 0, - afterRunID: Number.isFinite(Number(options.afterRunID)) && Number(options.afterRunID) > 0 ? Number(options.afterRunID) : 0, - workflowName: typeof options.workflowName === "string" ? options.workflowName.trim() : "", - engine: typeof options.engine === "string" ? options.engine.trim() : "", - branch: typeof options.branch === "string" ? options.branch.trim() : "", - artifacts, - }; -} - -function buildLogsArgs(options) { - const args = ["logs", "--json", "-c", String(options.count), "--timeout", String(options.timeout)]; - - if (options.workflowName) args.push(options.workflowName); - if (options.startDate) args.push("--start-date", options.startDate); - if (options.endDate) args.push("--end-date", options.endDate); - if (options.engine) args.push("--engine", options.engine); - if (options.branch) args.push("--ref", options.branch); - if (options.beforeRunID > 0) args.push("--before-run-id", String(options.beforeRunID)); - if (options.afterRunID > 0) args.push("--after-run-id", String(options.afterRunID)); - if (options.artifacts.length > 0) args.push("--artifacts", options.artifacts.join(",")); - - return args; -} - -function continuationToLogsOptions(continuation, fallback) { - if (!continuation) return null; - - return normalizeLogsOptions({ - window: fallback.window.id, - workflowName: continuation.workflow_name || fallback.workflowName, - count: continuation.count || fallback.count, - startDate: continuation.start_date || fallback.startDate, - endDate: continuation.end_date || fallback.endDate, - engine: continuation.engine || fallback.engine, - branch: continuation.branch || fallback.branch, - afterRunID: continuation.after_run_id || fallback.afterRunID, - beforeRunID: continuation.before_run_id || fallback.beforeRunID, - timeout: continuation.timeout || fallback.timeout, - artifacts: fallback.artifacts, - }); -} - -function mergeRuns(existingRuns, nextRuns) { - const merged = new Map(existingRuns.map(run => [run.run_id, run])); - for (const run of nextRuns) { - if (run?.run_id != null) { - merged.set(run.run_id, run); - } - } - return Array.from(merged.values()).sort((a, b) => Number(b.run_id ?? 0) - Number(a.run_id ?? 0)); -} - -async function fetchLogsBatches(initialOptions, initialArgs = null) { - let current = initialOptions; - let logsFetches = 0; - let runs = []; - let continuation = null; - let summary = null; - let firstBatch = null; - - while (current && logsFetches < MAX_LOG_CONTINUATIONS) { - const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); - let data; - try { - data = JSON.parse(raw); - } catch (error) { - throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${error.message}`); - } - if (!firstBatch) { - firstBatch = data; - } - runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); - continuation = data?.continuation ?? null; - summary = data?.summary ?? summary; - logsFetches += 1; - - if (!continuation) { - break; - } - - current = continuationToLogsOptions(continuation, initialOptions); - } - - return { - firstBatch, - runs, - summary, - logsFetches, - partial: Boolean(continuation), - continuation, - }; -} - -async function getLogsData(options = {}) { - const normalized = normalizeLogsOptions(options); - const key = `logs:${JSON.stringify({ - window: normalized.window.id, - count: normalized.count, - timeout: normalized.timeout, - startDate: normalized.startDate, - endDate: normalized.endDate, - beforeRunID: normalized.beforeRunID, - afterRunID: normalized.afterRunID, - workflowName: normalized.workflowName, - engine: normalized.engine, - branch: normalized.branch, - artifacts: normalized.artifacts, - })}`; - const hit = getCached(key); - if (hit) return hit; - - const logsResult = await fetchLogsBatches(normalized); - - const result = { - runs: logsResult.runs, - summary: logsResult.summary, - window: normalized.window, - timeout: normalized.timeout, - logsFetches: logsResult.logsFetches, - partial: logsResult.partial, - continuation: logsResult.continuation, - }; - setCached(key, result); - return result; -} - -async function getRuns(options = {}) { - return getLogsData(options); -} - -async function getForecastData(workflowIDs, window, timeout) { - if (!Array.isArray(workflowIDs) || workflowIDs.length === 0) { - return []; - } - - const args = ["forecast", "--json", "--period", "month", "--days", String(forecastDaysForWindow(window)), "--timeout", String(timeout), ...workflowIDs]; - const raw = await runGhAw(args); - let data; - try { - data = JSON.parse(raw); - } catch (error) { - const snippet = String(raw ?? "") - .replace(/\s+/g, " ") - .slice(0, 200); - throw new Error(`Failed to parse forecast output: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); - } - return Array.isArray(data?.workflows) ? data.workflows : []; -} - -async function getUsage(options = {}) { - const normalized = normalizeLogsOptions(options); - const key = `usage:${JSON.stringify({ - window: normalized.window.id, - count: normalized.count, - timeout: normalized.timeout, - })}`; - const hit = getCached(key); - if (hit) return hit; - const logsData = await getLogsData(normalized); - const usageItems = buildUsageSummary(logsData.runs, logsData.window); - const workflowIDs = usageItems.map(item => item.workflow_id).filter(Boolean); - const forecastWorkflows = await getForecastData(workflowIDs, logsData.window, logsData.timeout); - const result = { - items: applyForecastToUsageSummary(usageItems, forecastWorkflows), - window: logsData.window, - timeout: logsData.timeout, - logsFetches: logsData.logsFetches, - partial: logsData.partial, - continuation: logsData.continuation, - total_runs: logsData.runs.length, - forecast_history_days: forecastDaysForWindow(logsData.window), - }; - setCached(key, result); - return result; -} - -// --------------------------------------------------------------------------- -// Command runner for the Commands panel -// --------------------------------------------------------------------------- - -function parseGhAwArgs(raw) { - const m = raw.trim().match(/^(?:gh\s+aw\s+)(.+)$/); - return m ? m[1].trim().split(/\s+/) : null; -} - -function hasFlag(args, longFlag, shortFlag = "") { - return args.some(arg => { - if (arg.startsWith(`${longFlag}=`)) { - return true; - } - if (shortFlag && arg.startsWith(`${shortFlag}=`)) { - return true; - } - if (arg === longFlag || (shortFlag && arg === shortFlag)) { - return true; - } - return false; - }); -} - -function logsCommandUsesJSON(args) { - return hasFlag(args, "--json"); -} - -function normalizeLogsCommandArgs(args, windowId, timeout) { - const nextArgs = [...args]; - if (!hasFlag(nextArgs, "--start-date") && !hasFlag(nextArgs, "--end-date") && !hasFlag(nextArgs, "--after-run-id") && !hasFlag(nextArgs, "--before-run-id")) { - nextArgs.push("--start-date", getReportWindow(windowId).startDate); - } - if (!hasFlag(nextArgs, "--timeout")) { - nextArgs.push("--timeout", String(timeout)); - } - if (!hasFlag(nextArgs, "--artifacts")) { - nextArgs.push("--artifacts", "usage"); - } - return nextArgs; -} - -async function execCommand(rawCmd, options = {}) { - const args = parseGhAwArgs(rawCmd); - if (!args) { - return { command: rawCmd, output: "Only 'gh aw ' commands are supported.", error: true }; - } - try { - if (args[0] === "logs" && logsCommandUsesJSON(args)) { - const commandArgs = normalizeLogsCommandArgs(args, options.window, options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES); - const logsOptions = normalizeLogsOptions({ window: options.window, timeout: options.timeout }); - const logsResult = await fetchLogsBatches(logsOptions, commandArgs); - - return { - command: `gh aw ${commandArgs.join(" ")}`, - output: JSON.stringify( - { - ...(logsResult.firstBatch ?? {}), - runs: logsResult.runs, - partial: logsResult.partial, - logs_fetches: logsResult.logsFetches, - continuation: logsResult.continuation, - }, - null, - 2 - ), - }; - } - - const output = await runGhAw(args); - return { command: rawCmd, output }; - } catch (err) { - return { command: rawCmd, output: err.stderr || err.message, error: true }; - } -} +const runGhAw = createGhAwRunner({ getWorkspacePath: () => workspacePath }); +const dataAccess = createDashboardDataAccess({ runGhAw }); // --------------------------------------------------------------------------- // Pagination utility @@ -407,12 +62,12 @@ async function startServer() { res.setHeader("Content-Type", "application/javascript; charset=utf-8"); res.end(await readFile(join(__dirname, "web", "pagination.js"), "utf8")); } else if (pathname === "/api/status") { - sendJson(await getDefinitions()); + sendJson(await dataAccess.getDefinitions()); } else if (pathname === "/api/experiments") { - sendJson(await getExperiments()); + sendJson(await dataAccess.getExperiments()); } else if (pathname === "/api/runs") { sendJson( - await getRuns({ + await dataAccess.getRuns({ count: parseInt(reqUrl.searchParams.get("count") ?? String(DEFAULT_RUN_COUNT), 10), window: reqUrl.searchParams.get("window") ?? "7d", timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), @@ -420,7 +75,7 @@ async function startServer() { ); } else if (pathname === "/api/usage") { sendJson( - await getUsage({ + await dataAccess.getUsage({ count: parseInt(reqUrl.searchParams.get("count") ?? String(DEFAULT_RUN_COUNT), 10), window: reqUrl.searchParams.get("window") ?? "7d", timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), @@ -429,13 +84,13 @@ async function startServer() { } else if (pathname === "/api/run-command") { const cmd = reqUrl.searchParams.get("cmd") ?? ""; sendJson( - await execCommand(cmd, { + await dataAccess.execCommand(cmd, { window: reqUrl.searchParams.get("window") ?? "7d", timeout: parseInt(reqUrl.searchParams.get("timeout") ?? String(DEFAULT_LOG_TIMEOUT_MINUTES), 10), }) ); } else if (pathname === "/api/refresh") { - cache.clear(); + dataAccess.clearCache(); sendJson({ ok: true }); } else { res.writeHead(404); @@ -499,7 +154,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const defs = await getDefinitions(); + const defs = await dataAccess.getDefinitions(); return paginate(defs, Number(ctx.input?.page ?? 1), Number(ctx.input?.pageSize ?? 20)); }, }, @@ -518,7 +173,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const logsData = await getRuns({ + const logsData = await dataAccess.getRuns({ count: Number(ctx.input?.count ?? DEFAULT_RUN_COUNT), window: String(ctx.input?.window ?? "7d"), timeout: Number(ctx.input?.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), @@ -546,7 +201,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const usage = await getUsage({ + const usage = await dataAccess.getUsage({ count: Number(ctx.input?.count ?? DEFAULT_RUN_COUNT), window: String(ctx.input?.window ?? "7d"), timeout: Number(ctx.input?.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES), @@ -572,7 +227,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const experiments = await getExperiments(); + const experiments = await dataAccess.getExperiments(); return paginate(experiments, Number(ctx.input?.page ?? 1), Number(ctx.input?.pageSize ?? 20)); }, }, @@ -586,7 +241,7 @@ It never calls Go code directly — all data is fetched by running CLI subcomman additionalProperties: false, }, handler: async ctx => { - const logsData = await getRuns({ count: 200, window: "1mo", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }); + const logsData = await dataAccess.getRuns({ count: 200, window: "1mo", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }); return { run: logsData.runs.find(r => r.run_id === Number(ctx.input?.run_id)) ?? null }; }, }, @@ -599,14 +254,14 @@ It never calls Go code directly — all data is fetched by running CLI subcomman properties: { command: { type: "string", description: "Full command string starting with 'gh aw'." } }, additionalProperties: false, }, - handler: async ctx => execCommand(String(ctx.input?.command ?? ""), { window: "7d", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }), + handler: async ctx => dataAccess.execCommand(String(ctx.input?.command ?? ""), { window: "7d", timeout: DEFAULT_LOG_TIMEOUT_MINUTES }), }, { name: "refresh", description: "Clear the data cache so the next listDefinitions/listRuns fetches fresh data from the CLI.", inputSchema: { type: "object", additionalProperties: false }, handler: () => { - cache.clear(); + dataAccess.clearCache(); return { ok: true }; }, }, diff --git a/.github/extensions/agentic-workflows-dashboard/package.json b/.github/extensions/agentic-workflows-dashboard/package.json index 2704f919b4f..4336ff2f986 100644 --- a/.github/extensions/agentic-workflows-dashboard/package.json +++ b/.github/extensions/agentic-workflows-dashboard/package.json @@ -18,7 +18,7 @@ "fmt:ts": "npx prettier --write --parser typescript \"src/**/*.ts\" \"test/**/*.ts\" \"vitest.config.ts\"", "fmt:html": "npx prettier --write --parser html \"web/index.html\"", "fmt:css": "npx prettier --write --parser css \"web/styles.css\"", - "fmt:js": "npx prettier --write --parser babel \"extension.mjs\"", + "fmt:js": "npx prettier --write --parser babel \"*.mjs\"", "fmt:json": "npx prettier --write --parser json \"copilot-extension.json\" \"package.json\"", "impeccable:detect": "npx impeccable detect web/index.html web/styles.css" } diff --git a/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts b/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts new file mode 100644 index 00000000000..245c16fabbd --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { createDashboardDataAccess } from "../dashboard-data.mjs"; + +describe("dashboard data access", () => { + it("keeps logs command filters across continuation batches", async () => { + const calls: string[][] = []; + const dataAccess = createDashboardDataAccess({ + runGhAw: async args => { + calls.push(args); + if (calls.length === 1) { + return JSON.stringify({ + runs: [{ run_id: 100, workflow_name: "CI Doctor" }], + continuation: { before_run_id: 99 }, + }); + } + + return JSON.stringify({ + runs: [{ run_id: 99, workflow_name: "CI Doctor" }], + }); + }, + }); + + const result = await dataAccess.execCommand("gh aw logs ci-doctor --json -c 5 --engine claude", { window: "3d", timeout: 2 }); + const payload = JSON.parse(result.output); + + expect(calls).toEqual([ + ["logs", "ci-doctor", "--json", "-c", "5", "--engine", "claude", "--start-date", "-3d", "--timeout", "2", "--artifacts", "usage"], + ["logs", "--json", "-c", "5", "--timeout", "2", "ci-doctor", "--start-date", "-3d", "--engine", "claude", "--before-run-id", "99", "--artifacts", "usage"], + ]); + expect(payload.runs.map((run: { run_id: number }) => run.run_id)).toEqual([100, 99]); + expect(payload.logs_fetches).toBe(2); + expect(payload.partial).toBe(false); + }); + + it("passes timeout minutes through to forecast calls", async () => { + const calls: string[][] = []; + const dataAccess = createDashboardDataAccess({ + runGhAw: async args => { + calls.push(args); + if (args[0] === "logs") { + return JSON.stringify({ + runs: [{ run_id: 100, workflow_name: "CI Doctor", workflow_path: ".github/workflows/ci-doctor.lock.yml", aic: 12, created_at: "2026-06-29T12:00:00Z" }], + }); + } + + return JSON.stringify({ + workflows: [{ workflow_id: "ci-doctor", monthly_projected_aic: 44 }], + }); + }, + }); + + const usage = await dataAccess.getUsage({ window: "7d", timeout: 3 }); + + expect(calls[1]).toEqual(["forecast", "--json", "--period", "month", "--days", "7", "--timeout", "3", "ci-doctor"]); + expect(usage.items[0]?.monthly_forecast_aic).toBe(44); + }); +}); diff --git a/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts new file mode 100644 index 00000000000..3e8799b1b2e --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_LOG_TIMEOUT_MINUTES } from "../dashboard-config.mjs"; +import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, normalizeLogsCommandArgs, normalizeLogsOptions } from "../dashboard-logs.mjs"; + +describe("dashboard logs helpers", () => { + it("defaults logs timeouts in minutes", () => { + const options = normalizeLogsOptions({}); + + expect(options.timeout).toBe(DEFAULT_LOG_TIMEOUT_MINUTES); + expect(buildLogsArgs(options)).toEqual(expect.arrayContaining(["--timeout", String(DEFAULT_LOG_TIMEOUT_MINUTES)])); + }); + + it("preserves fallback filters when continuing logs pages", () => { + const initial = normalizeLogsOptions({ window: "3d", count: 5, timeout: 2, engine: "claude", workflowName: "ci-doctor" }); + const next = continuationToLogsOptions({ before_run_id: 90 }, initial); + + expect(next).toMatchObject({ + count: 5, + timeout: 2, + engine: "claude", + workflowName: "ci-doctor", + beforeRunID: 90, + startDate: "-3d", + }); + }); + + it("parses logs command args into continuation-ready options", () => { + const options = logsArgsToOptions(["logs", "ci-doctor", "--json", "-c", "5", "--engine", "claude", "--timeout", "3", "--before-run-id", "99"], { window: "7d" }); + + expect(options).toMatchObject({ + workflowName: "ci-doctor", + count: 5, + engine: "claude", + timeout: 3, + beforeRunID: 99, + startDate: "-1w", + }); + }); + + it("injects the selected report window and minute timeout when missing", () => { + expect(normalizeLogsCommandArgs(["logs", "--json"], "3d", 4)).toEqual(["logs", "--json", "--start-date", "-3d", "--timeout", "4", "--artifacts", "usage"]); + }); +}); diff --git a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs index c6658a9dd88..00472b5fa4b 100644 --- a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs +++ b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs @@ -34,11 +34,7 @@ export function getForecastMonthlyAIC(forecast) { export function applyForecastToUsageSummary(items, forecastWorkflows = []) { // Forecast results identify workflows by workflow_id; workflow_path is accepted as a // fallback so older or alternate JSON payloads can still be matched safely. - const forecastByWorkflow = new Map( - forecastWorkflows - .map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]) - .filter(([workflowID]) => Boolean(workflowID)) - ); + const forecastByWorkflow = new Map(forecastWorkflows.map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]).filter(([workflowID]) => Boolean(workflowID))); return items.map(item => ({ ...item, From 5224f6f12bc43f431f71aad2368fcba070ed6d80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:39:21 +0000 Subject: [PATCH 8/9] Polish dashboard helper refactor Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../extensions/agentic-workflows-dashboard/usage-forecast.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs index 00472b5fa4b..68aadd2a144 100644 --- a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs +++ b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs @@ -34,7 +34,8 @@ export function getForecastMonthlyAIC(forecast) { export function applyForecastToUsageSummary(items, forecastWorkflows = []) { // Forecast results identify workflows by workflow_id; workflow_path is accepted as a // fallback so older or alternate JSON payloads can still be matched safely. - const forecastByWorkflow = new Map(forecastWorkflows.map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]).filter(([workflowID]) => Boolean(workflowID))); + const forecastEntries = forecastWorkflows.map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]).filter(([workflowID]) => Boolean(workflowID)); + const forecastByWorkflow = new Map(forecastEntries); return items.map(item => ({ ...item, From 9a80515a60295f0a8d313706718b353604bfdb1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:11:49 +0000 Subject: [PATCH 9/9] Fix normalizeLogsOptions window-object handling and buildReportMessage fallback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agentic-workflows-dashboard/dashboard-logs.mjs | 3 ++- .../test/dashboard-logs.test.ts | 10 +++++++++- .../extensions/agentic-workflows-dashboard/web/app.js | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs index bc85cd09aaf..adcc6811732 100644 --- a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs @@ -14,7 +14,8 @@ function readFlagValue(args, index, arg) { } export function normalizeLogsOptions(options = {}) { - const window = getReportWindow(options.window); + const windowId = typeof options.window === "string" ? options.window : options.window?.id; + const window = getReportWindow(windowId); const artifacts = Array.isArray(options.artifacts) && options.artifacts.length > 0 ? options.artifacts : ["usage"]; return { diff --git a/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts index 3e8799b1b2e..d362fe35c5e 100644 --- a/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_LOG_TIMEOUT_MINUTES } from "../dashboard-config.mjs"; +import { DEFAULT_LOG_TIMEOUT_MINUTES, REPORT_WINDOWS } from "../dashboard-config.mjs"; import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, normalizeLogsCommandArgs, normalizeLogsOptions } from "../dashboard-logs.mjs"; describe("dashboard logs helpers", () => { @@ -11,6 +11,14 @@ describe("dashboard logs helpers", () => { expect(buildLogsArgs(options)).toEqual(expect.arrayContaining(["--timeout", String(DEFAULT_LOG_TIMEOUT_MINUTES)])); }); + it("accepts a window object in normalizeLogsOptions without falling back to default", () => { + const windowObj = REPORT_WINDOWS["3d"]; + const options = normalizeLogsOptions({ window: windowObj }); + + expect(options.window.id).toBe("3d"); + expect(options.startDate).toBe(windowObj.startDate); + }); + it("preserves fallback filters when continuing logs pages", () => { const initial = normalizeLogsOptions({ window: "3d", count: 5, timeout: 2, engine: "claude", workflowName: "ci-doctor" }); const next = continuationToLogsOptions({ before_run_id: 90 }, initial); diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index bd4d7455c90..a6a8ee01e2d 100644 --- a/.github/extensions/agentic-workflows-dashboard/web/app.js +++ b/.github/extensions/agentic-workflows-dashboard/web/app.js @@ -74,7 +74,7 @@ function reportWindowById(windowId) { function buildReportMessage(meta, emptyLabel) { if (!meta?.window) { - return ""; + return emptyLabel ?? ""; } const fragments = [`Window: ${meta.window.label}`];