Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/minor-sessions-download.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions actions/setup/js/session_cli.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @ts-check

const fs = require("fs");
const { isSessionEvent } = require("./agent_session.cjs");
const { collectUnifiedSession, parseEngineSession } = require("./unified_session.cjs");
const { serializeSessionArtifact } = require("./session_artifact.cjs");
const { generateCopilotCliStyleSummary } = require("./log_parser_shared.cjs");
const { validateSessionFileHeader } = require("./unified_session_render.cjs");

/** @param {string} inputPath @returns {string} */
function readSessionInput(inputPath) {
try {
return fs.readFileSync(inputPath, "utf8");
} catch (error) {
throw new Error(`Failed to read session input ${inputPath}`, { cause: error });
}
}

/**
* CLI adapter for the same parsers and renderers used by workflow summaries.
* Diagnostics go to stderr; stdout contains only the requested session text.
* @param {string[]} args
* @returns {string}
*/
function sessionCLI(args) {
const [mode, inputPath, engine] = args;
if (!inputPath) throw new Error("A session input path is required");
if (mode === "reconstruct") {
const { events, maskedValues } = collectUnifiedSession({
rootDir: inputPath,
...(engine ? { engine } : {}),
warn: message => console.error(message),
});
if (!events.some(event => event.provenance?.component === "agent")) throw new Error("No recognizable agent session found in the agent artifact");
return serializeSessionArtifact(events, maskedValues);
}
if (mode === "markdown") {
const events = readSessionInput(inputPath)
.split(/\r?\n/)
.filter(line => line.trim())
.map((line, index) => {
let event;
try {
event = JSON.parse(line);
} catch (error) {
throw new Error(`Invalid session JSONL at record ${index + 1}`, { cause: error });
}
if (!isSessionEvent(event)) throw new Error(`Invalid session event at record ${index + 1}`);
return event;
});
validateSessionFileHeader(events);
return generateCopilotCliStyleSummary(events) + "\n";
}
if (mode === "agent-markdown") {
if (!engine) throw new Error("An engine is required to parse an agent log");
return generateCopilotCliStyleSummary(parseEngineSession(readSessionInput(inputPath), engine)) + "\n";
}
throw new Error(`Unsupported session CLI mode: ${mode}`);
}

if (require.main === module) {
try {
process.stdout.write(sessionCLI(process.argv.slice(2)));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

module.exports = { sessionCLI };
78 changes: 78 additions & 0 deletions actions/setup/js/session_cli.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { sessionCLI } from "./session_cli.cjs";

describe("Session CLI adapter", () => {
let root;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-session-cli-"));
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});

function write(name, events) {
const file = path.join(root, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof events === "string" ? events : events.map(event => JSON.stringify(event)).join("\n") + "\n");
return file;
}

it("reconstructs compact unified JSONL with source provenance and no invented timestamps", () => {
write("agent-session.jsonl", [{ type: "assistant.message", id: "native-id", data: { content: " exact text\n" } }]);
const jsonl = sessionCLI(["reconstruct", root]);
const lines = jsonl.trimEnd().split("\n");
const events = lines.map(JSON.parse);
expect(events[0]).toMatchObject({ type: "session.format", data: { version: 1 } });
expect(events.find(event => event.type === "assistant.message")).toEqual({
type: "assistant.message",
id: "native-id",
data: { content: " exact text\n" },
provenance: { component: "agent", phase: "agent", path: "agent-session.jsonl", index: 0 },
});
expect(lines.every(line => JSON.stringify(JSON.parse(line)) === line)).toBe(true);
expect(jsonl.endsWith("\n")).toBe(true);
});

it("renders unified Markdown using the privacy-preserving shared summary", () => {
write("agent-session.jsonl", [
{ type: "user.message", data: { content: "private user prompt" } },
{ type: "assistant.message", data: { content: "Visible assistant response" } },
{ type: "tool.execution_start", data: { toolName: "bash", arguments: { command: "private shell command" } } },
]);
const file = write("usage/aw_session.jsonl", sessionCLI(["reconstruct", root]));
const markdown = sessionCLI(["markdown", file]);
expect(markdown).toContain("### Unified session");
expect(markdown).toContain("Visible assistant response");
expect(markdown).toContain("tool.execution_start");
expect(markdown).not.toContain("private user prompt");
expect(markdown).not.toContain("private shell command");
});

it("uses the engine parser for audit Markdown", () => {
const file = write("agent-stdio.log", '{"type":"item.completed","item":{"type":"agent_message","text":"Codex response"}}\n');
expect(sessionCLI(["agent-markdown", file, "codex"])).toContain("Codex response");
});

it("rejects unrecognized agent logs rather than returning a metadata-only session", () => {
write("agent-stdio.log", "unrecognized log text\n");
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => sessionCLI(["reconstruct", root, "claude"])).toThrow("No recognizable agent session");
});

it("rejects invalid session files and unsupported versions", () => {
expect(() => sessionCLI(["markdown", write("invalid.jsonl", "not JSON")])).toThrow();
expect(() => sessionCLI(["markdown", write("invalid-event.jsonl", [{ type: "assistant.message", data: [] }])])).toThrow("Invalid session event");
expect(() => sessionCLI(["markdown", write("missing-header.jsonl", [{ type: "assistant.message", data: {} }])])).toThrow("missing its leading");
expect(() => sessionCLI(["markdown", write("future.jsonl", [{ type: "session.format", data: { version: 2 }, provenance: { component: "collector" } }])])).toThrow("Unsupported unified session file-format version");
});

it("reports missing input, engine, and invalid modes", () => {
expect(() => sessionCLI([])).toThrow("input path is required");
expect(() => sessionCLI(["agent-markdown", root])).toThrow("engine is required");
expect(() => sessionCLI(["invalid", root])).toThrow("Unsupported session CLI mode");
});
});
2 changes: 1 addition & 1 deletion actions/setup/js/unified_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function parseEngineSession(content, engine) {
* Collect only known runtime directories; never follow artifact symlinks.
* Replicated firewall files use logs > audit > legacy precedence, including empty files.
* @param {{rootDir?: string, engine?: string, warn?: (message: string) => void}} [options]
* @returns {{events: SessionEvent[], maskedValues: string[]}}
* @returns {{events: import("./types/agent_session").UnifiedSession, maskedValues: string[]}}
*/
function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message => console.warn(message) } = {}) {
/** @type {SessionSource[]} */
Expand Down
18 changes: 18 additions & 0 deletions actions/setup/session_parsers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package setup

import "embed"

// SessionParserSources contains the runtime parsers and their dependencies, not
// test fixtures or workflow handlers. The CLI uses the same sources as Actions.
//
//go:embed js/session_cli.cjs js/unified_session.cjs js/unified_session_payload.cjs js/unified_session_render.cjs
//go:embed js/parse_claude_log.cjs js/parse_codex_log.cjs js/parse_copilot_log.cjs
//go:embed js/parse_custom_log.cjs js/parse_gemini_log.cjs js/parse_pi_log.cjs
//go:embed js/agent_session.cjs js/agent_session_render.cjs js/claude_session.cjs
//go:embed js/codex_session.cjs js/codex_log_framing.cjs js/copilot_session.cjs
//go:embed js/pi_session.cjs js/pi_session_redaction.cjs js/session_artifact.cjs
//go:embed js/log_parser_bootstrap.cjs js/log_parser_format.cjs js/log_parser_shared.cjs
//go:embed js/log_parser_step_summary_builder.cjs js/markdown_unfencing.cjs
//go:embed js/add_mask_redaction.cjs js/redact_secrets.cjs js/safe_output_manifest.cjs
//go:embed js/constants.cjs js/error_codes.cjs js/error_helpers.cjs js/shim.cjs
var SessionParserSources embed.FS
6 changes: 4 additions & 2 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ func runCompileCmd(cmd *cobra.Command, args []string) error {

type commandSet struct {
addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command
mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command
mcpCmd, logsCmd, auditCmd, sessionsCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command
fixCmd, formatCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command
domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd, workCmd *cobra.Command
}
Expand Down Expand Up @@ -734,6 +734,7 @@ func createCommandSet() commandSet {
mcpCmd: cli.NewMCPCommand(),
logsCmd: cli.NewLogsCommand(),
auditCmd: cli.NewAuditCommand(),
sessionsCmd: cli.NewSessionsCommand(),
viewCmd: cli.NewViewCommand(),
healthCmd: cli.NewHealthCommand(),
outcomesCmd: cli.NewOutcomesCommand(),
Expand Down Expand Up @@ -873,6 +874,7 @@ func assignCommandGroups(cmds commandSet) {
cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.formatCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development", "development"
runCmd.GroupID, enableCmd.GroupID, disableCmd.GroupID, cmds.trialCmd.GroupID = "execution", "execution", "execution", "execution"
cmds.logsCmd.GroupID, cmds.auditCmd.GroupID, cmds.viewCmd.GroupID = "analysis", "analysis", "analysis"
cmds.sessionsCmd.GroupID = "analysis"
cmds.healthCmd.GroupID, cmds.outcomesCmd.GroupID, cmds.checksCmd.GroupID = "analysis", "analysis", "analysis"
cmds.statusCmd.GroupID, cmds.listCmd.GroupID, cmds.experimentsCmd.GroupID, cmds.forecastCmd.GroupID, cmds.modelsCmd.GroupID = "analysis", "analysis", "analysis", "analysis", "analysis"
cmds.gradersCmd.GroupID = "analysis"
Expand All @@ -884,7 +886,7 @@ func assignCommandGroups(cmds commandSet) {
func addCommandsToRoot(cmds commandSet) {
rootCmd.AddCommand(
compileCmd, cmds.addCmd, cmds.addWizardCmd, cmds.editCmd, cmds.updateCmd, cmds.deployCmd, cmds.upgradeCmd, cmds.trialCmd, newCmd, cmds.initCmd,
runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.viewCmd,
runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.sessionsCmd, cmds.viewCmd,
cmds.healthCmd, cmds.outcomesCmd, cmds.checksCmd, cmds.mcpCmd, cmds.mcpServerCmd, cmds.prCmd, versionCmd, cmds.secretsCmd,
cmds.fixCmd, cmds.formatCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd,
cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.gradersCmd, cmds.modelsCmd, cmds.envCmd, cmds.jsonSchemaCmd, cmds.workCmd,
Expand Down
31 changes: 31 additions & 0 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,37 @@ The diff output shows: new or removed network domains, status changes (allowed

**Options:** `--artifacts`, `--format` (pretty, markdown; default: pretty), `--json/-j`, `--output/-o`, `--repo/-r`

#### `sessions`

Experimental: download a unified agent session for an existing workflow run. The command writes
`usage/aw_session.jsonl` to standard output without changing its contents. If that
file is absent, it reconstructs the session from the agent artifact using the same
engine parsers as `audit`, including available MCP and firewall evidence.
Reconstruction does not fetch missing downstream artifacts; the collection record
identifies absent components and source warnings.
Runs from older `gh-aw` versions do not need `aw_session.jsonl` or
`agent-session.jsonl`: native Copilot events, Pi streams, and raw engine logs are
parsed into the current unified format. Legacy `agent-artifacts` and standalone
`agent-stdio-log`/`agent-stdio.log` artifacts are supported, along with
`aw-info`/`aw_info` or activation metadata.

```bash wrap
gh aw sessions download 1234567890 --repo owner/repo
gh aw sessions download https://github.com/owner/repo/actions/runs/1234567890 --format markdown
gh aw sessions download 1234567890 -o session.jsonl
```

**Options:** `--format` (`jsonl` or `markdown`; default: `jsonl`),
`--output/-o` (output file), `--repo/-r` (`[HOST/]owner/repo`; defaults to the
current repository).

JSONL preserves the complete unified session payload. Markdown uses the shared,
bounded summary renderer, omits user prompts and private tool payloads, and
reports display truncation. Node.js is required for reconstruction and Markdown
rendering, but not for downloading an existing unified JSONL file. Missing
artifacts, download failures, and invalid session files return errors.
The command and its output formats may change.

#### `graders`

Run one grader declared by a local workflow against a saved run payload or JSON
Expand Down
99 changes: 5 additions & 94 deletions pkg/cli/logs_parsing_javascript.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
// This file provides command-line interface functionality for gh-aw.
// This file (logs_parsing_js.go) contains functionality for executing
// This file (logs_parsing_javascript.go) contains functionality for executing
// JavaScript log parsers to generate markdown summaries.
//
// Key responsibilities:
// - Running JavaScript log parser scripts
// - Mocking GitHub Actions environment for parsers
// - Sharing the session parsers used by workflow summaries
// - Generating markdown log summaries

package cli

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

Expand Down Expand Up @@ -51,98 +51,9 @@ func parseAgentLog(runDir string, engine workflow.CodingAgentEngine, verbose boo
return nil
}

jsScript := workflow.GetLogParserScript(parserScriptName)
if jsScript == "" {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Failed to get log parser script "+parserScriptName))
}
return nil
}

// Read the log content
logContent, err := os.ReadFile(agentLogPath)
if err != nil {
return fmt.Errorf("failed to read agent log file: %w", err)
}

// Create a temporary directory for running the parser
tempDir, err := os.MkdirTemp("", "log_parser")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)

// Write the log content to a temporary file
logFile := filepath.Join(tempDir, "agent.log")
if err := os.WriteFile(logFile, logContent, constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write log file: %w", err)
}

// Write the bootstrap helper to the temp directory
bootstrapScript := workflow.GetLogParserBootstrap()
if bootstrapScript != "" {
bootstrapFile := filepath.Join(tempDir, "log_parser_bootstrap.cjs")
if err := os.WriteFile(bootstrapFile, []byte(bootstrapScript), constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write bootstrap file: %w", err)
}
}

// Write the shared helper to the temp directory
sharedScript := workflow.GetJavaScriptSources()["log_parser_shared.cjs"]
if sharedScript != "" {
sharedFile := filepath.Join(tempDir, "log_parser_shared.cjs")
if err := os.WriteFile(sharedFile, []byte(sharedScript), constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write shared helper file: %w", err)
}
}

// Create a Node.js script that mimics the GitHub Actions environment
nodeScript := fmt.Sprintf(`
const fs = require('fs');

// Mock @actions/core for the parser
const core = {
summary: {
addRaw: function(content) {
this._content = content;
return this;
},
write: function() {
console.log(this._content);
},
_content: ''
},
setFailed: function(message) {
console.error('FAILED:', message);
process.exit(1);
},
info: function(message) {
// Silent in CLI mode
}
};

// Set global core for the parser scripts
global.core = core;

// Set up environment
process.env.GH_AW_AGENT_OUTPUT = '%s';

// Execute the parser script
%s
`, logFile, jsScript)

// Write the Node.js script
nodeFile := filepath.Join(tempDir, "parser.js")
if err := os.WriteFile(nodeFile, []byte(nodeScript), constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write node script: %w", err)
}

// Execute the Node.js script
cmd := exec.Command("node", "parser.js")
cmd.Dir = tempDir
output, err := cmd.CombinedOutput()
output, err := runSessionParser(context.Background(), "agent-markdown", agentLogPath, engine.GetID())

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.

This refactor drops behavior-defined engines’ custom log parsers, so gh aw audit can silently lose structured findings for any workflow that relies on a generated parser.

💡 Why this blocks merge

Before this change, audit keyed off engine.GetLogParserScriptId(), which is how behavior-defined engines surface their generated <engine>_log_parser.cjs parser. The new path passes only engine.GetID() into runSessionParser(), and session_cli.cjs only knows the built-in engine IDs; anything else falls back to parse_custom_log.cjs.

That means a custom engine with a real parser now degrades from its purpose-built parser to the generic heuristic parser, which can drop log entries, MCP failures, and max-turn detection from audit output even though the run itself produced the richer parser.

Please preserve the parser-script path for behavior-defined engines here — for example by materializing the engine-specific parser alongside the bundled runtime sources, or by teaching the session CLI to dispatch on GetLogParserScriptId() instead of only GetID().

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.

Fixed in acf1182: audit now materializes and invokes a behavior-defined engine’s generated parser source, preserving its structured entries and surfacing MCP failures and max-turn detection. Added regression coverage.

if err != nil {
return fmt.Errorf("failed to execute parser script: %w\nOutput: %s", err, string(output))
return err
}

// Write the output to log.md in the run directory
Expand Down
Loading
Loading