Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
Original file line number Diff line number Diff line change
@@ -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];
}
204 changes: 204 additions & 0 deletions .github/extensions/agentic-workflows-dashboard/dashboard-data.mjs
Original file line number Diff line number Diff line change
@@ -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}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The error path in fetchLogsBatches — when a mid-continuation batch returns unparseable JSON — has no test. If the CLI emits a warning line before the JSON, or returns a rate-limit error message, the thrown Error will surface as a 500 with a raw message rather than something actionable.

💡 Add a parse-failure test and consider a better error surface

Test:

it("throws a descriptive error when a continuation batch returns invalid JSON", async () => {
  let call = 0;
  const dataAccess = createDashboardDataAccess({
    runGhAw: async () => {
      call++;
      return call === 1
        ? JSON.stringify({ runs: [{ run_id: 1 }], continuation: { before_run_id: 0 } })
        : "not json";
    },
  });
  await expect(dataAccess.getRuns({ window: "7d" })).rejects.toThrow("Failed to parse logs batch 2");
});\n```

Consider propagating the partial result (the runs collected so far) alongside the error so callers can choose to surface what they have rather than failing completely.

</details>

@copilot please address this.

}
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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All unique workflow IDs are spread directly into the CLI argument list with no upper bound (...workflowIDs). With 6 continuation batches × 100 runs each, a repo could easily have 50+ distinct workflows. Most operating systems cap argument lists around 128–256 KB; if the cap is hit, execFile will throw E2BIG and the entire usage panel will fail.

Consider either:

  • capping the list (e.g., workflowIDs.slice(0, MAX_FORECAST_WORKFLOWS) with a defined constant), or
  • passing the IDs as a comma-separated string via a single flag if the CLI supports it.

@copilot please address this.

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,
})}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] The getUsage cache key omits several normalized fields (workflowName, engine, branch, startDate, endDate, artifacts), so two calls differing only in those parameters will incorrectly share a cached result — silently returning wrong data.

💡 Fix: mirror the complete key from `getLogsData`
const key = `usage:${JSON.stringify({
  window: normalized.window.id,
  count: normalized.count,
  timeout: normalized.timeout,
  startDate: normalized.startDate,
  endDate: normalized.endDate,
  workflowName: normalized.workflowName,
  engine: normalized.engine,
  branch: normalized.branch,
  artifacts: normalized.artifacts,
})}`;

Alternatively, since getUsage already delegates to getLogsData (which is cached with the full key), you can drop the usage: cache entry entirely and only cache the forecast-augmented result.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getUsage cache key only captures window, count, and timeout, but getUsage calls getLogsData(normalized) which respects the full normalized object (including workflowName, startDate, endDate, engine, branch). If a caller passes extra filters, getUsage will silently return a cached result from a different query.

Consider extending the key to include all fields that influence the underlying logs fetch, mirroring the getLogsData key:

const key = `usage:${JSON.stringify({
  window: normalized.window.id,
  count: normalized.count,
  timeout: normalized.timeout,
  startDate: normalized.startDate,
  workflowName: normalized.workflowName,
  engine: normalized.engine,
  branch: normalized.branch,
})}`;

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete cache key lets getUsage return stale results for filtered queries: the key excludes workflowName, engine, branch, startDate, endDate, beforeRunID, afterRunID, and artifacts — so two calls that differ only in those fields collide on the same cache entry.

💡 Suggested fix

Mirror the key construction from getLogsData:

const key = `usage:${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,
})}`;

Current callers only pass window/count/timeout, so this is latent — but getUsage accepts the full options object and will return wrong cached data the moment any caller adds a filter.

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 <subcommand>' 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,
};
}
Loading
Loading