diff --git a/.gitattributes b/.gitattributes index 43edb171d6f..8bec391cf43 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,7 @@ pkg/workflow/sh/*.sh linguist-generated=true actions/*/index.js linguist-generated=true actions/setup-cli/install.sh linguist-generated=true .github/extensions/agentic-workflows-dashboard/web/app.js linguist-generated=true merge=ours +.github/extensions/agentic-workflows-dashboard/*.js linguist-generated=true merge=ours specs/artifacts.md linguist-generated=true merge=ours docs/adr/*.md merge=theirs # Use bd merge for beads JSONL files diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-cli.js b/.github/extensions/agentic-workflows-dashboard/dashboard-cli.js new file mode 100644 index 00000000000..cbbb4d011ee --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-cli.js @@ -0,0 +1,177 @@ +import { spawn } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access } from "node:fs/promises"; +import { join } from "node:path"; +const INSTALL_COMMAND = "gh extension install github/gh-aw"; +const GH_INSTALL_URL = "https://cli.github.com"; +function combineOutput(stdout, stderr) { + return [stdout, stderr].filter(Boolean).join("\n").trim(); +} +function spawnExecFile(file, args, options, callback) { + const { env, cwd, maxBuffer = 10 * 1024 * 1024 } = options ?? {}; + const spawnOptions = { env, cwd, stdio: ["ignore", "pipe", "pipe"], detached: true }; + const proc = spawn(file, args, spawnOptions); + const stdoutChunks = []; + const stderrChunks = []; + let stdoutLen = 0; + let stderrLen = 0; + let overflowed = false; + proc.stdout?.on("data", (chunk) => { + stdoutLen += chunk.length; + if (stdoutLen > maxBuffer) { + overflowed = true; + return; + } + stdoutChunks.push(chunk); + }); + proc.stderr?.on("data", (chunk) => { + stderrLen += chunk.length; + if (stderrLen > maxBuffer) { + overflowed = true; + return; + } + stderrChunks.push(chunk); + }); + proc.on("error", err => callback(err, "", "")); + proc.on("close", code => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + if (overflowed) { + const err = new Error("stdout/stderr maxBuffer exceeded"); + err.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; + callback(err, stdout, stderr); + } + else if (code !== 0) { + const err = new Error(`Command failed with exit code ${code}`); + err.code = code ?? 1; + callback(err, stdout, stderr); + } + else { + callback(null, stdout, stderr); + } + }); +} +function execp(bin, args, cwd, { combineIO = false, execFileFn = spawnExecFile, env = process.env } = {}) { + return new Promise((resolve, reject) => { + execFileFn(bin, args, { + cwd, + env: { ...env, CI: "1", NO_COLOR: "1", GH_NO_UPDATE_NOTIFIER: "1" }, + maxBuffer: 10 * 1024 * 1024, + }, (err, stdout, stderr) => { + const output = combineOutput(stdout ?? "", stderr ?? ""); + if (err) { + reject(Object.assign(err, { stderr: stderr ?? "", stdout: stdout ?? "", output })); + return; + } + resolve(combineIO ? output : stdout); + }); + }); +} +function parseVersionFromOutput(output) { + const trimmed = String(output ?? "").trim(); + if (!trimmed) + return ""; + const match = trimmed.match(/gh(?:-aw| aw) version ([^\r\n]+)/i); + return match?.[1]?.trim() ?? ""; +} +function isMissingGh(error) { + const e = error; + return e?.code === "ENOENT" && e?.syscall === "spawn" && e?.path === "gh"; +} +function isMissingGhAwExtension(error) { + const e = error; + const output = String(e?.output ?? e?.stderr ?? e?.message ?? ""); + return /extension not found:\s*aw/i.test(output) || /unknown command ["']aw["'] for ["']gh["']/i.test(output); +} +async function findDevBinary(cwd, accessFn = access, platform = process.platform) { + const devBin = join(cwd, platform === "win32" ? "gh-aw.exe" : "gh-aw"); + try { + await accessFn(devBin, fsConstants.X_OK); + return devBin; + } + catch { + return null; + } +} +export function createGhAwRunner({ getWorkspacePath, accessFn = access, execFileFn = spawnExecFile, platform = process.platform, env = process.env }) { + async function runExec(bin, args, cwd, options) { + return execp(bin, args, cwd, { ...options, execFileFn, env }); + } + return async function runGhAw(args) { + const cwd = getWorkspacePath(); + const devBin = await findDevBinary(cwd, accessFn, platform); + if (devBin) { + return runExec(devBin, args, cwd); + } + return runExec("gh", ["aw", ...args], cwd); + }; +} +export function createGhAwRunnerWithStatus(options) { + const runGhAw = createGhAwRunner(options); + const getStatus = async () => { + const cwd = options.getWorkspacePath(); + const devBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform); + if (devBin) { + const output = await execp(devBin, ["version"], cwd, { + combineIO: true, + execFileFn: options.execFileFn ?? spawnExecFile, + env: options.env ?? process.env, + }); + return { + available: true, + source: "dev-binary", + version: parseVersionFromOutput(output) || "unknown", + command: `${devBin} version`, + installCommand: INSTALL_COMMAND, + }; + } + try { + const output = await execp("gh", ["aw", "version"], cwd, { + combineIO: true, + execFileFn: options.execFileFn ?? spawnExecFile, + env: options.env ?? process.env, + }); + return { + available: true, + source: "gh-extension", + version: parseVersionFromOutput(output) || "unknown", + command: "gh aw version", + installCommand: INSTALL_COMMAND, + }; + } + catch (error) { + if (isMissingGh(error)) { + return { + available: false, + source: "gh-not-found", + version: "", + command: "gh aw version", + installCommand: INSTALL_COMMAND, + installUrl: GH_INSTALL_URL, + message: "Install the GitHub CLI to use this dashboard.", + }; + } + if (isMissingGhAwExtension(error)) { + return { + available: false, + source: "missing", + version: "", + command: "gh aw version", + installCommand: INSTALL_COMMAND, + message: "gh aw is not installed. Install the GitHub CLI extension to use the dashboard outside a local dev build.", + }; + } + const e = error; + return { + available: false, + source: "error", + version: "", + command: "gh aw version", + installCommand: INSTALL_COMMAND, + message: String(e?.output ?? e?.stderr ?? e?.message ?? "Failed to detect gh aw."), + }; + } + }; + runGhAw.getStatus = getStatus; + return runGhAw; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-config.js b/.github/extensions/agentic-workflows-dashboard/dashboard-config.js new file mode 100644 index 00000000000..53f4c88e761 --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-config.js @@ -0,0 +1,16 @@ +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) { + if (windowId && windowId in REPORT_WINDOWS) { + return REPORT_WINDOWS[windowId]; + } + return REPORT_WINDOWS[DEFAULT_REPORT_WINDOW_ID]; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs b/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs deleted file mode 100644 index f4bcdceedef..00000000000 --- a/.github/extensions/agentic-workflows-dashboard/dashboard-config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -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.js b/.github/extensions/agentic-workflows-dashboard/dashboard-data.js new file mode 100644 index 00000000000..6286e7c75dd --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-data.js @@ -0,0 +1,224 @@ +import { CACHE_TTL_MS, DEFAULT_LOG_TIMEOUT_MINUTES, MAX_LOG_CONTINUATIONS } from "./dashboard-config.js"; +import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, logsCommandUsesJSON, mergeRuns, normalizeLogsCommandArgs, normalizeLogsOptions, parseGhAwArgs, } from "./dashboard-logs.js"; +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow } from "./usage-forecast.js"; +function asError(value) { + if (value instanceof Error) { + return value; + } + return new Error(String(value)); +} +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 parsed = JSON.parse(raw); + const data = Array.isArray(parsed) ? parsed : []; + setCached("definitions", data); + return data; + } + async function getExperiments() { + const hit = getCached("experiments"); + if (hit) + return hit; + const raw = await runGhAw(["experiments", "list", "--json"]); + const parsed = JSON.parse(raw); + const experiments = Array.isArray(parsed) ? parsed : []; + 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) { + const parsedError = asError(error); + throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${parsedError.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 (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 parsedError = asError(error); + const snippet = String(raw ?? "") + .replace(/\s+/g, " ") + .slice(0, 200); + throw new Error(`Failed to parse forecast output: ${parsedError.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 fallback = {}; + if (options.window) { + fallback.window = options.window; + } + if (options.timeout != null) { + fallback.timeout = options.timeout; + } + const logsOptions = logsArgsToOptions(commandArgs, fallback); + 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) { + const error = err; + return { command: rawCmd, output: error.stderr || error.message || "Unknown error", error: true }; + } + } + async function getAudit(runId) { + if (!runId) + return null; + const key = `audit:${runId}`; + const hit = getCached(key); + if (hit) + return hit; + const raw = await runGhAw(["audit", String(runId), "--json"]); + let data; + try { + data = JSON.parse(raw); + } + catch (error) { + const parsedError = asError(error); + const snippet = String(raw ?? "") + .replace(/\s+/g, " ") + .slice(0, 100); + throw new Error(`Failed to parse audit output for run ${runId}: ${parsedError.message}${snippet ? ` (output: ${snippet})` : ""}`); + } + setCached(key, data); + return data; + } + return { + clearCache: () => cache.clear(), + execCommand, + getAudit, + getDefinitions, + getExperiments, + getRuns, + getUsage, + }; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.js b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.js new file mode 100644 index 00000000000..239990c81da --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.js @@ -0,0 +1,189 @@ +import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT, getReportWindow } from "./dashboard-config.js"; +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 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 { + 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?.[1] ? 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 7222ca8e030..c92dcc8da79 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url"; import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; -import { createGhAwRunnerWithStatus } from "./dashboard-cli.mjs"; -import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT } from "./dashboard-config.mjs"; -import { createDashboardDataAccess } from "./dashboard-data.mjs"; +import { createGhAwRunnerWithStatus } from "./dashboard-cli.js"; +import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT } from "./dashboard-config.js"; +import { createDashboardDataAccess } from "./dashboard-data.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const servers = new Map(); diff --git a/.github/extensions/agentic-workflows-dashboard/package-lock.json b/.github/extensions/agentic-workflows-dashboard/package-lock.json index 487e416ec6e..a352b2d879c 100644 --- a/.github/extensions/agentic-workflows-dashboard/package-lock.json +++ b/.github/extensions/agentic-workflows-dashboard/package-lock.json @@ -11,6 +11,7 @@ "alpinejs": "^3.15.0" }, "devDependencies": { + "@types/node": "^26.0.1", "esbuild": "^0.28.1", "typescript": "6.0.3", "vitest": "4.1.9" @@ -835,6 +836,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1602,6 +1613,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", diff --git a/.github/extensions/agentic-workflows-dashboard/package.json b/.github/extensions/agentic-workflows-dashboard/package.json index 49cca23a63f..46ba2fbce6b 100644 --- a/.github/extensions/agentic-workflows-dashboard/package.json +++ b/.github/extensions/agentic-workflows-dashboard/package.json @@ -8,6 +8,7 @@ "alpinejs": "^3.15.0" }, "devDependencies": { + "@types/node": "^26.0.1", "esbuild": "^0.28.1", "typescript": "6.0.3", "vitest": "4.1.9" diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs b/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts similarity index 56% rename from .github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs rename to .github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts index 5b536b31bd4..073c58e22a9 100644 --- a/.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs +++ b/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type SpawnOptions } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import { access } from "node:fs/promises"; import { join } from "node:path"; @@ -6,29 +6,70 @@ import { join } from "node:path"; const INSTALL_COMMAND = "gh extension install github/gh-aw"; const GH_INSTALL_URL = "https://cli.github.com"; -function combineOutput(stdout, stderr) { +type ExecError = Error & { + code?: string | number; + syscall?: string; + path?: string; + stderr?: string; + stdout?: string; + output?: string; +}; + +type ExecCallback = (err: ExecError | null, stdout: string, stderr: string) => void; + +type ExecFileLike = (file: string, args: string[], options: ExecOptions, callback: ExecCallback) => void; + +type AccessLike = typeof access; + +interface ExecOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + maxBuffer?: number; +} + +interface RunExecOptions { + combineIO?: boolean; + execFileFn?: ExecFileLike; + env?: NodeJS.ProcessEnv; +} + +interface RunnerOptions { + getWorkspacePath: () => string; + accessFn?: AccessLike; + execFileFn?: ExecFileLike; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; +} + +export interface GhAwStatus { + available: boolean; + source: "dev-binary" | "gh-extension" | "gh-not-found" | "missing" | "error"; + version: string; + command: string; + installCommand: string; + installUrl?: string; + message?: string; +} + +export type GhAwRunner = ((args: string[]) => Promise) & { + getStatus: () => Promise; +}; + +function combineOutput(stdout: string, stderr: string): string { return [stdout, stderr].filter(Boolean).join("\n").trim(); } -/** - * Wraps spawn() with the same callback signature as execFile(), but uses - * stdio: ['ignore', 'pipe', 'pipe'] so the child process never blocks waiting - * for stdin. This is important in environments where the parent process holds - * a special stdin handle (e.g. Copilot CLI) that causes the child to hang. - */ -function spawnExecFile(file, args, options, callback) { +function spawnExecFile(file: string, args: string[], options: ExecOptions, callback: ExecCallback): void { const { env, cwd, maxBuffer = 10 * 1024 * 1024 } = options ?? {}; - // detached: true prevents the child from inheriting the parent's special - // handles (e.g. Copilot CLI named pipes) that would otherwise cause gh-aw - // to block indefinitely waiting on an inherited pipe it never owns. - const proc = spawn(file, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"], detached: true }); - const stdoutChunks = []; - const stderrChunks = []; + const spawnOptions: SpawnOptions = { env, cwd, stdio: ["ignore", "pipe", "pipe"], detached: true }; + const proc = spawn(file, args, spawnOptions); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; let stdoutLen = 0; let stderrLen = 0; let overflowed = false; - proc.stdout.on("data", chunk => { + proc.stdout?.on("data", (chunk: Buffer) => { stdoutLen += chunk.length; if (stdoutLen > maxBuffer) { overflowed = true; @@ -36,7 +77,8 @@ function spawnExecFile(file, args, options, callback) { } stdoutChunks.push(chunk); }); - proc.stderr.on("data", chunk => { + + proc.stderr?.on("data", (chunk: Buffer) => { stderrLen += chunk.length; if (stderrLen > maxBuffer) { overflowed = true; @@ -45,17 +87,17 @@ function spawnExecFile(file, args, options, callback) { stderrChunks.push(chunk); }); - proc.on("error", err => callback(err, "", "")); + proc.on("error", err => callback(err as ExecError, "", "")); proc.on("close", code => { const stdout = Buffer.concat(stdoutChunks).toString("utf8"); const stderr = Buffer.concat(stderrChunks).toString("utf8"); if (overflowed) { - const err = new Error("stdout/stderr maxBuffer exceeded"); + const err: ExecError = new Error("stdout/stderr maxBuffer exceeded"); err.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; callback(err, stdout, stderr); } else if (code !== 0) { - const err = new Error(`Command failed with exit code ${code}`); - err.code = code; + const err: ExecError = new Error(`Command failed with exit code ${code}`); + err.code = code ?? 1; callback(err, stdout, stderr); } else { callback(null, stdout, stderr); @@ -63,7 +105,7 @@ function spawnExecFile(file, args, options, callback) { }); } -function execp(bin, args, cwd, { combineIO = false, execFileFn = spawnExecFile, env = process.env } = {}) { +function execp(bin: string, args: string[], cwd: string, { combineIO = false, execFileFn = spawnExecFile, env = process.env }: RunExecOptions = {}): Promise { return new Promise((resolve, reject) => { execFileFn( bin, @@ -75,30 +117,35 @@ function execp(bin, args, cwd, { combineIO = false, execFileFn = spawnExecFile, }, (err, stdout, stderr) => { const output = combineOutput(stdout ?? "", stderr ?? ""); - if (err) reject(Object.assign(err, { stderr: stderr ?? "", stdout: stdout ?? "", output })); - else resolve(combineIO ? output : stdout); + if (err) { + reject(Object.assign(err, { stderr: stderr ?? "", stdout: stdout ?? "", output })); + return; + } + resolve(combineIO ? output : stdout); } ); }); } -function parseVersionFromOutput(output) { +function parseVersionFromOutput(output: string): string { const trimmed = String(output ?? "").trim(); if (!trimmed) return ""; const match = trimmed.match(/gh(?:-aw| aw) version ([^\r\n]+)/i); return match?.[1]?.trim() ?? ""; } -function isMissingGh(error) { - return error?.code === "ENOENT" && error?.syscall === "spawn" && error?.path === "gh"; +function isMissingGh(error: unknown): boolean { + const e = error as ExecError | undefined; + return e?.code === "ENOENT" && e?.syscall === "spawn" && e?.path === "gh"; } -function isMissingGhAwExtension(error) { - const output = String(error?.output ?? error?.stderr ?? error?.message ?? ""); +function isMissingGhAwExtension(error: unknown): boolean { + const e = error as ExecError | undefined; + const output = String(e?.output ?? e?.stderr ?? e?.message ?? ""); return /extension not found:\s*aw/i.test(output) || /unknown command ["']aw["'] for ["']gh["']/i.test(output); } -async function findDevBinary(cwd, accessFn = access, platform = process.platform) { +async function findDevBinary(cwd: string, accessFn: AccessLike = access, platform: NodeJS.Platform = process.platform): Promise { const devBin = join(cwd, platform === "win32" ? "gh-aw.exe" : "gh-aw"); try { await accessFn(devBin, fsConstants.X_OK); @@ -108,12 +155,12 @@ async function findDevBinary(cwd, accessFn = access, platform = process.platform } } -export function createGhAwRunner({ getWorkspacePath, accessFn = access, execFileFn = spawnExecFile, platform = process.platform, env = process.env }) { - async function runExec(bin, args, cwd, options) { +export function createGhAwRunner({ getWorkspacePath, accessFn = access, execFileFn = spawnExecFile, platform = process.platform, env = process.env }: RunnerOptions): (args: string[]) => Promise { + async function runExec(bin: string, args: string[], cwd: string, options?: RunExecOptions): Promise { return execp(bin, args, cwd, { ...options, execFileFn, env }); } - return async function runGhAw(args) { + return async function runGhAw(args: string[]): Promise { const cwd = getWorkspacePath(); const devBin = await findDevBinary(cwd, accessFn, platform); if (devBin) { @@ -124,9 +171,9 @@ export function createGhAwRunner({ getWorkspacePath, accessFn = access, execFile }; } -export function createGhAwRunnerWithStatus(options) { - const runGhAw = createGhAwRunner(options); - const getStatus = async () => { +export function createGhAwRunnerWithStatus(options: RunnerOptions): GhAwRunner { + const runGhAw = createGhAwRunner(options) as GhAwRunner; + const getStatus = async (): Promise => { const cwd = options.getWorkspacePath(); const devBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform); @@ -182,13 +229,14 @@ export function createGhAwRunnerWithStatus(options) { }; } + const e = error as ExecError | undefined; return { available: false, source: "error", version: "", command: "gh aw version", installCommand: INSTALL_COMMAND, - message: String(error?.output ?? error?.stderr ?? error?.message ?? "Failed to detect gh aw."), + message: String(e?.output ?? e?.stderr ?? e?.message ?? "Failed to detect gh aw."), }; } }; diff --git a/.github/extensions/agentic-workflows-dashboard/src/dashboard-config.ts b/.github/extensions/agentic-workflows-dashboard/src/dashboard-config.ts new file mode 100644 index 00000000000..ece9cb523af --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/src/dashboard-config.ts @@ -0,0 +1,27 @@ +export const CACHE_TTL_MS = 60_000; +export const DEFAULT_LOG_TIMEOUT_MINUTES = 1; +export const DEFAULT_REPORT_WINDOW_ID = "7d" as const; +export const DEFAULT_RUN_COUNT = 100; +export const MAX_LOG_CONTINUATIONS = 6; + +export type ReportWindowID = "3d" | "7d" | "1mo"; + +export interface ReportWindow { + id: ReportWindowID; + label: string; + startDate: string; + days: number; +} + +export const REPORT_WINDOWS: Record = { + "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?: string | null): ReportWindow { + if (windowId && windowId in REPORT_WINDOWS) { + return REPORT_WINDOWS[windowId as ReportWindowID]; + } + return REPORT_WINDOWS[DEFAULT_REPORT_WINDOW_ID]; +} diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-data.mjs b/.github/extensions/agentic-workflows-dashboard/src/dashboard-data.ts similarity index 50% rename from .github/extensions/agentic-workflows-dashboard/dashboard-data.mjs rename to .github/extensions/agentic-workflows-dashboard/src/dashboard-data.ts index b1c1d0fd6d0..aa40f4cdbc8 100644 --- a/.github/extensions/agentic-workflows-dashboard/dashboard-data.mjs +++ b/.github/extensions/agentic-workflows-dashboard/src/dashboard-data.ts @@ -1,60 +1,143 @@ -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"; +import { CACHE_TTL_MS, DEFAULT_LOG_TIMEOUT_MINUTES, MAX_LOG_CONTINUATIONS, type ReportWindow } from "./dashboard-config.js"; +import { + buildLogsArgs, + continuationToLogsOptions, + logsArgsToOptions, + logsCommandUsesJSON, + mergeRuns, + normalizeLogsCommandArgs, + normalizeLogsOptions, + parseGhAwArgs, + type LogsContinuation, + type LogsOptions, + type LogsOptionsInput, + type RunLike, +} from "./dashboard-logs.js"; +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow, type ForecastWorkflow, type UsageRun, type UsageSummaryItem } from "./usage-forecast.js"; -export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) { - const cache = new Map(); +interface CacheEntry { + data: T; + expiresAt: number; +} + +interface LogsBatchResponse { + runs?: RunLike[]; + continuation?: LogsContinuation; + summary?: unknown; + [key: string]: unknown; +} + +interface ForecastResponse { + workflows?: ForecastWorkflow[]; +} + +interface LogsBatchResult { + firstBatch: LogsBatchResponse | null; + runs: RunLike[]; + summary: unknown; + logsFetches: number; + partial: boolean; + continuation: LogsContinuation | null; +} + +interface LogsDataResult { + runs: RunLike[]; + summary: unknown; + window: ReportWindow; + timeout: number; + logsFetches: number; + partial: boolean; + continuation: LogsContinuation | null; +} - function getCached(key) { +interface UsageResult { + items: UsageSummaryItem[]; + window: ReportWindow; + timeout: number; + logsFetches: number; + partial: boolean; + continuation: LogsContinuation | null; + total_runs: number; + forecast_history_days: number; +} + +interface ExecCommandOptions { + window?: string; + timeout?: number; +} + +interface ExecCommandResult { + command: string; + output: string; + error?: boolean; +} + +type RunGhAw = (args: string[]) => Promise; + +function asError(value: unknown): Error { + if (value instanceof Error) { + return value; + } + return new Error(String(value)); +} + +export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }: { runGhAw: RunGhAw; cacheTTL?: number }) { + const cache = new Map>(); + + function getCached(key: string): T | null { const entry = cache.get(key); - return entry && Date.now() < entry.expiresAt ? entry.data : null; + return entry && Date.now() < entry.expiresAt ? (entry.data as T) : null; } - function setCached(key, data) { + function setCached(key: string, data: T): void { cache.set(key, { data, expiresAt: Date.now() + cacheTTL }); } - async function getDefinitions() { - const hit = getCached("definitions"); + async function getDefinitions(): Promise { + const hit = getCached("definitions"); if (hit) return hit; const raw = await runGhAw(["status", "--json"]); - const data = JSON.parse(raw); + const parsed = JSON.parse(raw); + const data = Array.isArray(parsed) ? parsed : []; setCached("definitions", data); return data; } - async function getExperiments() { - const hit = getCached("experiments"); + async function getExperiments(): Promise { + 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 : []; + const parsed = JSON.parse(raw); + const experiments = Array.isArray(parsed) ? parsed : []; setCached("experiments", experiments); return experiments; } - async function fetchLogsBatches(initialOptions, initialArgs = null) { - let current = initialOptions; + async function fetchLogsBatches(initialOptions: LogsOptions, initialArgs: string[] | null = null): Promise { + let current: LogsOptions | null = initialOptions; let logsFetches = 0; - let runs = []; - let continuation = null; - let summary = null; - let firstBatch = null; + let runs: RunLike[] = []; + let continuation: LogsContinuation | null = null; + let summary: unknown = null; + let firstBatch: LogsBatchResponse | null = null; while (current && logsFetches < MAX_LOG_CONTINUATIONS) { const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); - let data; + let data: LogsBatchResponse; try { - data = JSON.parse(raw); + data = JSON.parse(raw) as LogsBatchResponse; } catch (error) { - throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${error.message}`); + const parsedError = asError(error); + throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${parsedError.message}`); } + if (!firstBatch) { firstBatch = data; } - runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); - continuation = data?.continuation ?? null; - summary = data?.summary ?? summary; + + runs = mergeRuns(runs, Array.isArray(data.runs) ? data.runs : []); + continuation = data.continuation ?? null; + summary = data.summary ?? summary; logsFetches += 1; if (!continuation) { @@ -74,7 +157,7 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) }; } - async function getLogsData(options = {}) { + async function getLogsData(options: LogsOptionsInput = {}): Promise { const normalized = normalizeLogsOptions(options); const key = `logs:${JSON.stringify({ window: normalized.window.id, @@ -89,12 +172,12 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) branch: normalized.branch, artifacts: normalized.artifacts, })}`; - const hit = getCached(key); + const hit = getCached(key); if (hit) return hit; const logsResult = await fetchLogsBatches(normalized); - const result = { + const result: LogsDataResult = { runs: logsResult.runs, summary: logsResult.summary, window: normalized.window, @@ -107,44 +190,45 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) return result; } - async function getForecastData(workflowIDs, window, timeout) { - if (!Array.isArray(workflowIDs) || workflowIDs.length === 0) { + async function getForecastData(workflowIDs: string[], window: ReportWindow, timeout: number): Promise { + if (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; + let data: ForecastResponse; try { - data = JSON.parse(raw); + data = JSON.parse(raw) as ForecastResponse; } catch (error) { + const parsedError = asError(error); const snippet = String(raw ?? "") .replace(/\s+/g, " ") .slice(0, 200); - throw new Error(`Failed to parse forecast output: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); + throw new Error(`Failed to parse forecast output: ${parsedError.message}${snippet ? ` (output: ${snippet})` : ""}`); } - return Array.isArray(data?.workflows) ? data.workflows : []; + return Array.isArray(data.workflows) ? data.workflows : []; } - async function getRuns(options = {}) { + async function getRuns(options: LogsOptionsInput = {}): Promise { return getLogsData(options); } - async function getUsage(options = {}) { + async function getUsage(options: LogsOptionsInput = {}): Promise { const normalized = normalizeLogsOptions(options); const key = `usage:${JSON.stringify({ window: normalized.window.id, count: normalized.count, timeout: normalized.timeout, })}`; - const hit = getCached(key); + const hit = getCached(key); if (hit) return hit; const logsData = await getLogsData(normalized); - const usageItems = buildUsageSummary(logsData.runs, logsData.window); + const usageItems = buildUsageSummary(logsData.runs as UsageRun[], logsData.window); const workflowIDs = usageItems.map(item => item.workflow_id).filter(Boolean); const forecastWorkflows = await getForecastData(workflowIDs, logsData.window, logsData.timeout); - const result = { + const result: UsageResult = { items: applyForecastToUsageSummary(usageItems, forecastWorkflows), window: logsData.window, timeout: logsData.timeout, @@ -158,7 +242,7 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) return result; } - async function execCommand(rawCmd, options = {}) { + async function execCommand(rawCmd: string, options: ExecCommandOptions = {}): Promise { const args = parseGhAwArgs(rawCmd); if (!args) { return { command: rawCmd, output: "Only 'gh aw ' commands are supported.", error: true }; @@ -167,7 +251,14 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) 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 fallback: LogsOptionsInput = {}; + if (options.window) { + fallback.window = options.window; + } + if (options.timeout != null) { + fallback.timeout = options.timeout; + } + const logsOptions = logsArgsToOptions(commandArgs, fallback); const logsResult = await fetchLogsBatches(logsOptions, commandArgs); return { @@ -189,25 +280,27 @@ export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) const output = await runGhAw(args); return { command: rawCmd, output }; } catch (err) { - return { command: rawCmd, output: err.stderr || err.message, error: true }; + const error = err as { stderr?: string; message?: string }; + return { command: rawCmd, output: error.stderr || error.message || "Unknown error", error: true }; } } - async function getAudit(runId) { + async function getAudit(runId: string | number): Promise { if (!runId) return null; const key = `audit:${runId}`; - const hit = getCached(key); + const hit = getCached(key); if (hit) return hit; const raw = await runGhAw(["audit", String(runId), "--json"]); - let data; + let data: unknown; try { data = JSON.parse(raw); } catch (error) { + const parsedError = asError(error); const snippet = String(raw ?? "") .replace(/\s+/g, " ") .slice(0, 100); - throw new Error(`Failed to parse audit output for run ${runId}: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); + throw new Error(`Failed to parse audit output for run ${runId}: ${parsedError.message}${snippet ? ` (output: ${snippet})` : ""}`); } setCached(key, data); return data; diff --git a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs b/.github/extensions/agentic-workflows-dashboard/src/dashboard-logs.ts similarity index 72% rename from .github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs rename to .github/extensions/agentic-workflows-dashboard/src/dashboard-logs.ts index adcc6811732..0b10226dc20 100644 --- a/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs +++ b/.github/extensions/agentic-workflows-dashboard/src/dashboard-logs.ts @@ -1,11 +1,56 @@ -import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT, getReportWindow } from "./dashboard-config.mjs"; +import { DEFAULT_LOG_TIMEOUT_MINUTES, DEFAULT_RUN_COUNT, getReportWindow, type ReportWindow, type ReportWindowID } from "./dashboard-config.js"; -function parsePositiveInt(value, fallback) { +export interface RunLike { + run_id?: number | string | null; + [key: string]: unknown; +} + +export interface LogsOptionsInput { + window?: ReportWindow | ReportWindowID | string | undefined; + count?: number | string | undefined; + timeout?: number | string | undefined; + startDate?: string | undefined; + endDate?: string | undefined; + beforeRunID?: number | string | undefined; + afterRunID?: number | string | undefined; + workflowName?: string | undefined; + engine?: string | undefined; + branch?: string | undefined; + artifacts?: string[] | undefined; +} + +export interface LogsOptions { + window: ReportWindow; + count: number; + timeout: number; + startDate: string; + endDate: string; + beforeRunID: number; + afterRunID: number; + workflowName: string; + engine: string; + branch: string; + artifacts: string[]; +} + +export interface LogsContinuation { + workflow_name?: string; + count?: number | string; + start_date?: string; + end_date?: string; + engine?: string; + branch?: string; + after_run_id?: number | string; + before_run_id?: number | string; + timeout?: number | string; +} + +function parsePositiveInt(value: unknown, fallback: number): number { const numeric = Number.parseInt(String(value ?? fallback), 10); return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; } -function readFlagValue(args, index, arg) { +function readFlagValue(args: string[], index: number, arg: string): { value: string; nextIndex: number } { const equalsIndex = arg.indexOf("="); if (equalsIndex >= 0) { return { value: arg.slice(equalsIndex + 1), nextIndex: index }; @@ -13,7 +58,7 @@ function readFlagValue(args, index, arg) { return { value: args[index + 1] ?? "", nextIndex: index + 1 }; } -export function normalizeLogsOptions(options = {}) { +export function normalizeLogsOptions(options: LogsOptionsInput = {}): LogsOptions { 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"]; @@ -33,7 +78,7 @@ export function normalizeLogsOptions(options = {}) { }; } -export function buildLogsArgs(options) { +export function buildLogsArgs(options: LogsOptions): string[] { const args = ["logs", "--json", "-c", String(options.count), "--timeout", String(options.timeout)]; if (options.workflowName) args.push(options.workflowName); @@ -48,7 +93,7 @@ export function buildLogsArgs(options) { return args; } -export function continuationToLogsOptions(continuation, fallback) { +export function continuationToLogsOptions(continuation: LogsContinuation | null | undefined, fallback: LogsOptions): LogsOptions | null { if (!continuation) return null; return normalizeLogsOptions({ @@ -66,38 +111,35 @@ export function continuationToLogsOptions(continuation, fallback) { }); } -export function mergeRuns(existingRuns, nextRuns) { +export function mergeRuns(existingRuns: RunLike[], nextRuns: RunLike[]): RunLike[] { 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) { +export function parseGhAwArgs(raw: string): string[] | null { const match = raw.trim().match(/^(?:gh\s+aw\s+)(.+)$/); - return match ? match[1].trim().split(/\s+/) : null; + return match?.[1] ? match[1].trim().split(/\s+/) : null; } -export function hasFlag(args, longFlag, shortFlag = "") { +export function hasFlag(args: string[], longFlag: string, shortFlag = ""): boolean { return args.some(arg => { - if (arg.startsWith(`${longFlag}=`)) { - return true; - } - if (shortFlag && arg.startsWith(`${shortFlag}=`)) { - return true; - } - return arg === longFlag || (shortFlag && arg === shortFlag); + if (arg.startsWith(`${longFlag}=`)) return true; + if (shortFlag && arg.startsWith(`${shortFlag}=`)) return true; + return arg === longFlag || (shortFlag !== "" && arg === shortFlag); }); } -export function logsCommandUsesJSON(args) { +export function logsCommandUsesJSON(args: string[]): boolean { return hasFlag(args, "--json", "-j"); } -export function normalizeLogsCommandArgs(args, windowId, timeoutMinutes) { +export function normalizeLogsCommandArgs(args: string[], windowId: string | undefined, timeoutMinutes: number): string[] { 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); @@ -111,8 +153,8 @@ export function normalizeLogsCommandArgs(args, windowId, timeoutMinutes) { return nextArgs; } -export function logsArgsToOptions(args, fallback = {}) { - const options = { +export function logsArgsToOptions(args: string[], fallback: LogsOptionsInput = {}): LogsOptions { + const options: LogsOptionsInput = { window: typeof fallback.window === "string" ? fallback.window : fallback.window?.id, count: fallback.count, timeout: fallback.timeout, @@ -127,7 +169,7 @@ export function logsArgsToOptions(args, fallback = {}) { }; for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; + const arg = args[index] ?? ""; if (!arg.startsWith("-")) { if (!options.workflowName) { diff --git a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs b/.github/extensions/agentic-workflows-dashboard/src/usage-forecast.ts similarity index 66% rename from .github/extensions/agentic-workflows-dashboard/usage-forecast.mjs rename to .github/extensions/agentic-workflows-dashboard/src/usage-forecast.ts index 68aadd2a144..537cdea8b4b 100644 --- a/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs +++ b/.github/extensions/agentic-workflows-dashboard/src/usage-forecast.ts @@ -1,11 +1,46 @@ import { basename } from "node:path"; -export function toNumber(value) { +export interface ForecastMonthlyMonteCarlo { + p50_projected_aic?: number; +} + +export interface ForecastWorkflow { + workflow_id?: string; + workflow_path?: string; + monthly_monte_carlo?: ForecastMonthlyMonteCarlo; + monthly_projected_aic?: number; +} + +export interface UsageSummaryItem { + workflow_id: string; + workflow_name: string; + workflow_path: string; + run_count: number; + total_aic: number; + cost_per_run: number; + daily_aic: number; + monthly_forecast_aic: number; + last_run_at: string; +} + +export interface UsageWindow { + id?: string; + days?: number; +} + +export interface UsageRun { + workflow_name?: string; + workflow_path?: string; + aic?: number; + created_at?: string; +} + +export function toNumber(value: unknown): number { const numeric = Number(value ?? 0); return Number.isFinite(numeric) ? numeric : 0; } -export function normalizeWorkflowID(value) { +export function normalizeWorkflowID(value: unknown): string { const raw = String(value ?? "").trim(); if (!raw) return ""; @@ -20,21 +55,19 @@ export function normalizeWorkflowID(value) { return name.trim(); } -export function forecastDaysForWindow(window) { +export function forecastDaysForWindow(window?: UsageWindow | null): number { return window?.id === "1mo" ? 30 : 7; } -export function getForecastMonthlyAIC(forecast) { +export function getForecastMonthlyAIC(forecast?: ForecastWorkflow | null): number { 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 forecastEntries = forecastWorkflows.map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)]).filter(([workflowID]) => Boolean(workflowID)); +export function applyForecastToUsageSummary(items: UsageSummaryItem[], forecastWorkflows: ForecastWorkflow[] = []): UsageSummaryItem[] { + const forecastEntries = forecastWorkflows.map(forecast => [normalizeWorkflowID(forecast?.workflow_id || forecast?.workflow_path), getForecastMonthlyAIC(forecast)] as const).filter(([workflowID]) => Boolean(workflowID)); const forecastByWorkflow = new Map(forecastEntries); return items.map(item => ({ @@ -43,8 +76,8 @@ export function applyForecastToUsageSummary(items, forecastWorkflows = []) { })); } -export function buildUsageSummary(runs, window, forecastWorkflows = []) { - const usageByWorkflow = new Map(); +export function buildUsageSummary(runs: UsageRun[], window: UsageWindow, forecastWorkflows: ForecastWorkflow[] = []): UsageSummaryItem[] { + 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.`); @@ -57,7 +90,7 @@ export function buildUsageSummary(runs, window, forecastWorkflows = []) { const workflowName = String(run?.workflow_name ?? workflowID).trim() || workflowID; const aic = toNumber(run?.aic); - const entry = usageByWorkflow.get(workflowID) ?? { + const entry: UsageSummaryItem = usageByWorkflow.get(workflowID) ?? { workflow_id: workflowID, workflow_name: workflowName, workflow_path: workflowPath, diff --git a/.github/extensions/agentic-workflows-dashboard/test/dashboard-cli.test.ts b/.github/extensions/agentic-workflows-dashboard/test/dashboard-cli.test.ts index 4155febcd59..2725829b300 100644 --- a/.github/extensions/agentic-workflows-dashboard/test/dashboard-cli.test.ts +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { createGhAwRunnerWithStatus } from "../dashboard-cli.mjs"; +import { createGhAwRunnerWithStatus } from "../src/dashboard-cli.js"; describe("dashboard cli runner", () => { it("detects gh aw version from the extension and sets CI=1", async () => { diff --git a/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts b/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts index 245c16fabbd..2d0aef4c2e2 100644 --- a/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createDashboardDataAccess } from "../dashboard-data.mjs"; +import { createDashboardDataAccess } from "../src/dashboard-data.js"; describe("dashboard data access", () => { it("keeps logs command filters across continuation batches", async () => { 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 d362fe35c5e..49948013662 100644 --- a/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_LOG_TIMEOUT_MINUTES, REPORT_WINDOWS } from "../dashboard-config.mjs"; -import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, normalizeLogsCommandArgs, normalizeLogsOptions } from "../dashboard-logs.mjs"; +import { DEFAULT_LOG_TIMEOUT_MINUTES, REPORT_WINDOWS } from "../src/dashboard-config.js"; +import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, normalizeLogsCommandArgs, normalizeLogsOptions } from "../src/dashboard-logs.js"; describe("dashboard logs helpers", () => { it("defaults logs timeouts in minutes", () => { diff --git a/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts b/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts index b96b1b8f185..475f0fd33a3 100644 --- a/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts +++ b/.github/extensions/agentic-workflows-dashboard/test/usage-forecast.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow, getForecastMonthlyAIC, normalizeWorkflowID } from "../usage-forecast.mjs"; +import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow, getForecastMonthlyAIC, normalizeWorkflowID } from "../src/usage-forecast.js"; describe("usage forecast helpers", () => { it("normalizes workflow ids from workflow paths", () => { diff --git a/.github/extensions/agentic-workflows-dashboard/tsconfig.json b/.github/extensions/agentic-workflows-dashboard/tsconfig.json index a1a9e519be1..18e8198ca54 100644 --- a/.github/extensions/agentic-workflows-dashboard/tsconfig.json +++ b/.github/extensions/agentic-workflows-dashboard/tsconfig.json @@ -4,12 +4,13 @@ "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], + "types": ["node"], "strict": true, "noImplicitAny": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "outDir": "./web", - "rootDir": "./src", + "rootDir": ".", "skipLibCheck": true }, "include": ["src/**/*.ts"] diff --git a/.github/extensions/agentic-workflows-dashboard/usage-forecast.js b/.github/extensions/agentic-workflows-dashboard/usage-forecast.js new file mode 100644 index 00000000000..06fb578f67b --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/usage-forecast.js @@ -0,0 +1,97 @@ +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 = []) { + 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, + 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); +}