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..adcc6811732 --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/dashboard-logs.mjs @@ -0,0 +1,210 @@ +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 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 ? 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 2638886d789..c6f522ee501 100644 --- a/.github/extensions/agentic-workflows-dashboard/extension.mjs +++ b/.github/extensions/agentic-workflows-dashboard/extension.mjs @@ -1,119 +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 { 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; 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; -} - -async function getRuns(count = 50) { - const key = `runs:${count}`; - 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; -} - -// --------------------------------------------------------------------------- -// 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; -} - -async function execCommand(rawCmd) { - const args = parseGhAwArgs(rawCmd); - if (!args) { - return { command: rawCmd, output: "Only 'gh aw ' commands are supported.", error: true }; - } - try { - 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 @@ -162,17 +62,35 @@ 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") { - const count = parseInt(reqUrl.searchParams.get("count") ?? "50", 10); - sendJson(await getRuns(count)); + sendJson( + 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), + }) + ); + } else if (pathname === "/api/usage") { + sendJson( + 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), + }) + ); } else if (pathname === "/api/run-command") { const cmd = reqUrl.searchParams.get("cmd") ?? ""; - sendJson(await execCommand(cmd)); + sendJson( + 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); @@ -201,7 +119,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 +128,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 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 @@ -235,25 +154,65 @@ 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)); }, }, { 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 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), + }); + 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 from gh aw logs and monthly forecast costs from gh aw forecast.", 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 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), + }); + 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, + }; }, }, { @@ -268,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)); }, }, @@ -282,8 +241,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 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 }; }, }, { @@ -295,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 ?? "")), + 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..d362fe35c5e --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/test/dashboard-logs.test.ts @@ -0,0 +1,52 @@ +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"; + +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("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); + + 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/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..68aadd2a144 --- /dev/null +++ b/.github/extensions/agentic-workflows-dashboard/usage-forecast.mjs @@ -0,0 +1,107 @@ +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 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); +} diff --git a/.github/extensions/agentic-workflows-dashboard/web/app.js b/.github/extensions/agentic-workflows-dashboard/web/app.js index dd579786c02..a6a8ee01e2d 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" }, +]; +const DEFAULT_LOGS_COMMAND_COUNT = 25; + 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(undefined, 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 emptyLabel ?? ""; + } + + 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(); + 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(); + 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 previousRunId = this.selectedRun?.run_id ?? null; + 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); + 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; + } 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 = DEFAULT_LOGS_COMMAND_COUNT) { + const window = this.currentWindow(); + return `gh aw logs --json -c ${count} --start-date ${window.startDate} --timeout ${this.logsTimeout}`; + }, + + buildReportSummaryMessage(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..f52d0afc7b9 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; +}