Skip to content

Commit a9771f7

Browse files
authored
Add experimental sessions download with legacy run reconstruction (#65263)
1 parent cf8696c commit a9771f7

16 files changed

Lines changed: 1290 additions & 96 deletions

‎.changeset/minor-sessions-download.md‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎actions/setup/js/session_cli.cjs‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// @ts-check
2+
3+
const fs = require("fs");
4+
const { isSessionEvent } = require("./agent_session.cjs");
5+
const { collectUnifiedSession, parseEngineSession } = require("./unified_session.cjs");
6+
const { serializeSessionArtifact } = require("./session_artifact.cjs");
7+
const { generateCopilotCliStyleSummary } = require("./log_parser_shared.cjs");
8+
const { validateSessionFileHeader } = require("./unified_session_render.cjs");
9+
10+
/** @param {string} inputPath @returns {string} */
11+
function readSessionInput(inputPath) {
12+
try {
13+
return fs.readFileSync(inputPath, "utf8");
14+
} catch (error) {
15+
throw new Error(`Failed to read session input ${inputPath}`, { cause: error });
16+
}
17+
}
18+
19+
/**
20+
* CLI adapter for the same parsers and renderers used by workflow summaries.
21+
* Diagnostics go to stderr; stdout contains only the requested session text.
22+
* @param {string[]} args
23+
* @returns {string}
24+
*/
25+
function sessionCLI(args) {
26+
const [mode, inputPath, engine, customParser] = args;
27+
if (!inputPath) throw new Error("A session input path is required");
28+
if (mode === "reconstruct") {
29+
const { events, maskedValues } = collectUnifiedSession({
30+
rootDir: inputPath,
31+
...(engine ? { engine } : {}),
32+
warn: message => console.error(message),
33+
});
34+
if (!events.some(event => event.provenance?.component === "agent")) throw new Error("No recognizable agent session found in the agent artifact");
35+
return serializeSessionArtifact(events, maskedValues);
36+
}
37+
if (mode === "markdown") {
38+
const events = readSessionInput(inputPath)
39+
.split(/\r?\n/)
40+
.filter(line => line.trim())
41+
.map((line, index) => {
42+
let event;
43+
try {
44+
event = JSON.parse(line);
45+
} catch (error) {
46+
throw new Error(`Invalid session JSONL at record ${index + 1}`, { cause: error });
47+
}
48+
if (!isSessionEvent(event)) throw new Error(`Invalid session event at record ${index + 1}`);
49+
return event;
50+
});
51+
validateSessionFileHeader(events);
52+
return generateCopilotCliStyleSummary(events) + "\n";
53+
}
54+
if (mode === "agent-markdown") {
55+
if (!engine) throw new Error("An engine is required to parse an agent log");
56+
if (customParser) {
57+
if (customParser !== "behavior_log_parser.cjs") throw new Error("Invalid custom log parser");
58+
const parsed = require("./" + customParser).parseLog(readSessionInput(inputPath));
59+
if (typeof parsed === "string") return parsed + "\n";
60+
if (Array.isArray(parsed?.mcpFailures) && parsed.mcpFailures.length > 0) {
61+
throw new Error(`MCP server(s) failed to launch: ${parsed.mcpFailures.join(", ")}`);
62+
}
63+
if (parsed?.maxTurnsHit) throw new Error("Agent execution stopped: max-turns limit reached");
64+
if (Array.isArray(parsed?.logEntries) && parsed.logEntries.length > 0) {
65+
return generateCopilotCliStyleSummary(parsed.logEntries) + "\n";
66+
}
67+
if (typeof parsed?.markdown === "string") return parsed.markdown + "\n";
68+
return generateCopilotCliStyleSummary(parsed?.logEntries ?? []) + "\n";
69+
}
70+
return generateCopilotCliStyleSummary(parseEngineSession(readSessionInput(inputPath), engine)) + "\n";
71+
}
72+
throw new Error(`Unsupported session CLI mode: ${mode}`);
73+
}
74+
75+
if (require.main === module) {
76+
try {
77+
process.stdout.write(sessionCLI(process.argv.slice(2)));
78+
} catch (error) {
79+
console.error(error instanceof Error ? error.message : String(error));
80+
process.exitCode = 1;
81+
}
82+
}
83+
84+
module.exports = { sessionCLI };
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { sessionCLI } from "./session_cli.cjs";
6+
7+
describe("Session CLI adapter", () => {
8+
let root;
9+
beforeEach(() => {
10+
root = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-session-cli-"));
11+
});
12+
afterEach(() => {
13+
vi.restoreAllMocks();
14+
fs.rmSync(root, { recursive: true, force: true });
15+
});
16+
17+
function write(name, events) {
18+
const file = path.join(root, name);
19+
fs.mkdirSync(path.dirname(file), { recursive: true });
20+
fs.writeFileSync(file, typeof events === "string" ? events : events.map(event => JSON.stringify(event)).join("\n") + "\n");
21+
return file;
22+
}
23+
24+
it("reconstructs compact unified JSONL with source provenance and no invented timestamps", () => {
25+
write("agent-session.jsonl", [{ type: "assistant.message", id: "native-id", data: { content: " exact text\n" } }]);
26+
const jsonl = sessionCLI(["reconstruct", root]);
27+
const lines = jsonl.trimEnd().split("\n");
28+
const events = lines.map(JSON.parse);
29+
expect(events[0]).toMatchObject({ type: "session.format", data: { version: 1 } });
30+
expect(events.find(event => event.type === "assistant.message")).toEqual({
31+
type: "assistant.message",
32+
id: "native-id",
33+
data: { content: " exact text\n" },
34+
provenance: { component: "agent", phase: "agent", path: "agent-session.jsonl", index: 0 },
35+
});
36+
expect(lines.every(line => JSON.stringify(JSON.parse(line)) === line)).toBe(true);
37+
expect(jsonl.endsWith("\n")).toBe(true);
38+
});
39+
40+
it("renders unified Markdown using the privacy-preserving shared summary", () => {
41+
write("agent-session.jsonl", [
42+
{ type: "user.message", data: { content: "private user prompt" } },
43+
{ type: "assistant.message", data: { content: "Visible assistant response" } },
44+
{ type: "tool.execution_start", data: { toolName: "bash", arguments: { command: "private shell command" } } },
45+
]);
46+
const file = write("usage/aw_session.jsonl", sessionCLI(["reconstruct", root]));
47+
const markdown = sessionCLI(["markdown", file]);
48+
expect(markdown).toContain("### Unified session");
49+
expect(markdown).toContain("Visible assistant response");
50+
expect(markdown).toContain("tool.execution_start");
51+
expect(markdown).not.toContain("private user prompt");
52+
expect(markdown).not.toContain("private shell command");
53+
});
54+
55+
it("uses the engine parser for audit Markdown", () => {
56+
const file = write("agent-stdio.log", '{"type":"item.completed","item":{"type":"agent_message","text":"Codex response"}}\n');
57+
expect(sessionCLI(["agent-markdown", file, "codex"])).toContain("Codex response");
58+
});
59+
60+
it("rejects unrecognized agent logs rather than returning a metadata-only session", () => {
61+
write("agent-stdio.log", "unrecognized log text\n");
62+
vi.spyOn(console, "error").mockImplementation(() => {});
63+
expect(() => sessionCLI(["reconstruct", root, "claude"])).toThrow("No recognizable agent session");
64+
});
65+
66+
it("rejects invalid session files and unsupported versions", () => {
67+
expect(() => sessionCLI(["markdown", write("invalid.jsonl", "not JSON")])).toThrow();
68+
expect(() => sessionCLI(["markdown", write("invalid-event.jsonl", [{ type: "assistant.message", data: [] }])])).toThrow("Invalid session event");
69+
expect(() => sessionCLI(["markdown", write("missing-header.jsonl", [{ type: "assistant.message", data: {} }])])).toThrow("missing its leading");
70+
expect(() => sessionCLI(["markdown", write("future.jsonl", [{ type: "session.format", data: { version: 2 }, provenance: { component: "collector" } }])])).toThrow("Unsupported unified session file-format version");
71+
});
72+
73+
it("reports missing input, engine, and invalid modes", () => {
74+
expect(() => sessionCLI([])).toThrow("input path is required");
75+
expect(() => sessionCLI(["agent-markdown", root])).toThrow("engine is required");
76+
expect(() => sessionCLI(["invalid", root])).toThrow("Unsupported session CLI mode");
77+
});
78+
});

‎actions/setup/js/unified_session.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ function parseEngineSession(content, engine) {
117117
* Collect only known runtime directories; never follow artifact symlinks.
118118
* Replicated firewall files use logs > audit > legacy precedence, including empty files.
119119
* @param {{rootDir?: string, engine?: string, warn?: (message: string) => void}} [options]
120-
* @returns {{events: SessionEvent[], maskedValues: string[]}}
120+
* @returns {{events: import("./types/agent_session").UnifiedSession, maskedValues: string[]}}
121121
*/
122122
function collectUnifiedSession({ rootDir = "/tmp/gh-aw", engine, warn = message => console.warn(message) } = {}) {
123123
/** @type {SessionSource[]} */

‎actions/setup/session_parsers.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package setup
2+
3+
import "embed"
4+
5+
// SessionParserSources contains the runtime parsers and their dependencies, not
6+
// test fixtures or workflow handlers. The CLI uses the same sources as Actions.
7+
//
8+
//go:embed js/session_cli.cjs js/unified_session.cjs js/unified_session_payload.cjs js/unified_session_render.cjs
9+
//go:embed js/parse_claude_log.cjs js/parse_codex_log.cjs js/parse_copilot_log.cjs js/parse_gemini_log.cjs
10+
//go:embed js/parse_custom_log.cjs js/parse_pi_log.cjs
11+
//go:embed js/agent_session.cjs js/agent_session_render.cjs js/claude_session.cjs
12+
//go:embed js/codex_session.cjs js/codex_log_framing.cjs js/copilot_session.cjs
13+
//go:embed js/pi_session.cjs js/pi_session_redaction.cjs js/session_artifact.cjs
14+
//go:embed js/log_parser_bootstrap.cjs js/log_parser_format.cjs js/log_parser_shared.cjs
15+
//go:embed js/log_parser_step_summary_builder.cjs js/markdown_unfencing.cjs
16+
//go:embed js/add_mask_redaction.cjs js/redact_secrets.cjs js/safe_output_manifest.cjs
17+
//go:embed js/constants.cjs js/error_codes.cjs js/error_helpers.cjs js/shim.cjs
18+
var SessionParserSources embed.FS

‎cmd/gh-aw/main.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ func runCompileCmd(cmd *cobra.Command, args []string) error {
574574

575575
type commandSet struct {
576576
addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command
577-
mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command
577+
mcpCmd, logsCmd, auditCmd, sessionsCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command
578578
fixCmd, formatCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command
579579
domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd, workCmd *cobra.Command
580580
}
@@ -734,6 +734,7 @@ func createCommandSet() commandSet {
734734
mcpCmd: cli.NewMCPCommand(),
735735
logsCmd: cli.NewLogsCommand(),
736736
auditCmd: cli.NewAuditCommand(),
737+
sessionsCmd: cli.NewSessionsCommand(),
737738
viewCmd: cli.NewViewCommand(),
738739
healthCmd: cli.NewHealthCommand(),
739740
outcomesCmd: cli.NewOutcomesCommand(),
@@ -873,6 +874,7 @@ func assignCommandGroups(cmds commandSet) {
873874
cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.formatCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development", "development"
874875
runCmd.GroupID, enableCmd.GroupID, disableCmd.GroupID, cmds.trialCmd.GroupID = "execution", "execution", "execution", "execution"
875876
cmds.logsCmd.GroupID, cmds.auditCmd.GroupID, cmds.viewCmd.GroupID = "analysis", "analysis", "analysis"
877+
cmds.sessionsCmd.GroupID = "analysis"
876878
cmds.healthCmd.GroupID, cmds.outcomesCmd.GroupID, cmds.checksCmd.GroupID = "analysis", "analysis", "analysis"
877879
cmds.statusCmd.GroupID, cmds.listCmd.GroupID, cmds.experimentsCmd.GroupID, cmds.forecastCmd.GroupID, cmds.modelsCmd.GroupID = "analysis", "analysis", "analysis", "analysis", "analysis"
878880
cmds.gradersCmd.GroupID = "analysis"
@@ -884,7 +886,7 @@ func assignCommandGroups(cmds commandSet) {
884886
func addCommandsToRoot(cmds commandSet) {
885887
rootCmd.AddCommand(
886888
compileCmd, cmds.addCmd, cmds.addWizardCmd, cmds.editCmd, cmds.updateCmd, cmds.deployCmd, cmds.upgradeCmd, cmds.trialCmd, newCmd, cmds.initCmd,
887-
runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.viewCmd,
889+
runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.sessionsCmd, cmds.viewCmd,
888890
cmds.healthCmd, cmds.outcomesCmd, cmds.checksCmd, cmds.mcpCmd, cmds.mcpServerCmd, cmds.prCmd, versionCmd, cmds.secretsCmd,
889891
cmds.fixCmd, cmds.formatCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd,
890892
cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.gradersCmd, cmds.modelsCmd, cmds.envCmd, cmds.jsonSchemaCmd, cmds.workCmd,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ADR-65263: Export Unified Session Downloads
2+
3+
**Date**: 2026-10-03
4+
**Status**: Draft
5+
**Deciders**: gh-aw maintainers (review pending)
6+
7+
---
8+
9+
### Context
10+
11+
This pull request adds a new `gh aw sessions download <run-id-or-url>` command so operators can export workflow sessions without generating a full audit report. Recent runs may already publish `usage/aw_session.jsonl`, but older runs often only contain agent artifacts, raw engine logs, or legacy metadata layouts. The implementation must preserve published JSONL byte-for-byte when it already exists, support historical artifact names and nested layouts, and provide both machine-readable JSONL and privacy-preserving Markdown output. The command also needs to work outside a source checkout, so the runtime parser sources used by Actions and audit summaries must be packaged for CLI reuse.
12+
13+
### Decision
14+
15+
We will expose session export as a dedicated `gh aw sessions download` command that first prefers the published `usage/aw_session.jsonl` artifact and falls back to reconstructing the unified session from downloaded agent evidence when the published file is absent. We will reuse the same JavaScript session parsers and renderers already used by audit and workflow summaries by embedding their runtime sources in Go and executing them through a shared CLI adapter. We will validate unified session headers and records before returning output, support both `jsonl` and privacy-preserving `markdown` formats, and only reconstruct data from artifacts that are actually present instead of inventing missing evidence.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Download only published `aw_session.jsonl`
20+
21+
This would have been the simplest implementation because the CLI could just fetch the usage artifact and stream it to stdout. It was not chosen because the PR explicitly targets historical runs that predate unified session publication and therefore need reconstruction from agent artifacts, engine logs, and legacy metadata layouts.
22+
23+
#### Alternative 2: Build a sessions-specific parser pipeline separate from audit
24+
25+
A dedicated parser stack could have been tailored only for `sessions download` and avoided packaging the current audit parser sources for reuse outside a source checkout. It was not chosen because the PR intentionally shares the existing engine parsers, Markdown renderer, and validation path with audit and workflow summaries so session exports stay consistent across commands and do not fork parsing logic.
26+
27+
### Consequences
28+
29+
#### Positive
30+
- Users can export a unified session directly from a workflow run without generating a full audit report.
31+
- Historical runs remain usable because the command can reconstruct the current unified format from older artifact names, raw engine logs, and legacy directory layouts.
32+
- Session parsing and Markdown rendering stay consistent with audit and workflow summaries because the same runtime sources are reused.
33+
34+
#### Negative
35+
- The CLI now depends on packaging and executing embedded Node.js parser sources, which increases implementation complexity and introduces a runtime requirement for reconstruction and Markdown rendering.
36+
- Session download logic must handle multiple artifact naming conventions, metadata sources, and flattening behaviors, which increases maintenance cost.
37+
- Reconstruction can only report evidence that exists in downloaded artifacts, so older runs may still produce incomplete sessions when key artifacts are missing.
38+
39+
#### Neutral
40+
- Published `aw_session.jsonl` remains the preferred source of truth and is returned byte-for-byte when present.
41+
- Markdown output is intentionally privacy-preserving and bounded, while JSONL remains the complete machine-readable format.
42+
- The command is introduced as experimental, so its interface and output details can evolve as session export needs become clearer.
43+
44+
---
45+
46+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

‎docs/src/content/docs/setup/cli.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,37 @@ The diff output shows: new or removed network domains, status changes (allowed
634634

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

637+
#### `sessions`
638+
639+
Experimental: download a unified agent session for an existing workflow run. The command writes
640+
`usage/aw_session.jsonl` to standard output without changing its contents. If that
641+
file is absent, it reconstructs the session from the agent artifact using the same
642+
engine parsers as `audit`, including available MCP and firewall evidence.
643+
Reconstruction does not fetch missing downstream artifacts; the collection record
644+
identifies absent components and source warnings.
645+
Runs from older `gh-aw` versions do not need `aw_session.jsonl` or
646+
`agent-session.jsonl`: native Copilot events, Pi streams, and raw engine logs are
647+
parsed into the current unified format. Legacy `agent-artifacts` and standalone
648+
`agent-stdio-log`/`agent-stdio.log` artifacts are supported, along with
649+
`aw-info`/`aw_info` or activation metadata.
650+
651+
```bash wrap
652+
gh aw sessions download 1234567890 --repo owner/repo
653+
gh aw sessions download https://github.com/owner/repo/actions/runs/1234567890 --format markdown
654+
gh aw sessions download 1234567890 -o session.jsonl
655+
```
656+
657+
**Options:** `--format` (`jsonl` or `markdown`; default: `jsonl`),
658+
`--output/-o` (output file), `--repo/-r` (`[HOST/]owner/repo`; defaults to the
659+
current repository).
660+
661+
JSONL preserves the complete unified session payload. Markdown uses the shared,
662+
bounded summary renderer, omits user prompts and private tool payloads, and
663+
reports display truncation. Node.js is required for reconstruction and Markdown
664+
rendering, but not for downloading an existing unified JSONL file. Missing
665+
artifacts, download failures, and invalid session files return errors.
666+
The command and its output formats may change.
667+
637668
#### `graders`
638669

639670
Run one grader declared by a local workflow against a saved run payload or JSON

0 commit comments

Comments
 (0)