diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 815673c..0fa4750 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -15,7 +15,7 @@ import { AgentRunError, errorToJson } from "../../src/common/errors.js"; import type { RunnerOnceOptions } from "../../src/runner/run-once.js"; import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; import { outputBytesFromPayload, outputTruncatedFromPayload } from "../../src/common/output.js"; -import { redactText } from "../../src/common/redaction.js"; +import { redactJson, redactText } from "../../src/common/redaction.js"; interface ParsedArgs { positional: string[]; @@ -70,7 +70,7 @@ async function dispatch(args: ParsedArgs): Promise { if (group === "sessions" && command === "storage" && id) return sessionStorageGet(args, id); if (group === "sessions" && command === "storage" && !id) throw new AgentRunError("schema-invalid", "sessions storage requires a sessionId", { httpStatus: 2 }); if (group === "sessions" && command === "show" && id) return client(args).get(`/api/v1/sessions/${encodeURIComponent(id)}${readerQuery(args)}`); - if (group === "sessions" && command === "read" && id) return client(args).post(`/api/v1/sessions/${encodeURIComponent(id)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" }); + if (group === "sessions" && command === "read" && id) return sessionRead(args, id); if (group === "sessions" && command === "trace" && id) return sessionEvents(args, id, "trace"); if (group === "sessions" && command === "output" && id) return sessionEvents(args, id, "output"); if (group === "sessions" && command === "turn") return sessionTurn(args, id ?? null); @@ -181,6 +181,16 @@ interface RunEventSummaryOptions { summaryChars: number; } +interface SessionEventSummaryOptions { + kind: "trace" | "output"; + sessionId: string; + afterSeq: number; + limit: number; + runId: string | null; + tail: number | null; + summaryChars: number; +} + export function summarizeRunEventPage(page: JsonValue, options: RunEventSummaryOptions): JsonRecord { const events = eventPageItems(page); const selected = options.tail === null ? events : events.slice(-options.tail); @@ -201,6 +211,69 @@ export function summarizeRunEventPage(page: JsonValue, options: RunEventSummaryO }; } +export function summarizeSessionEventPage(page: JsonValue, options: SessionEventSummaryOptions): JsonRecord { + const record = jsonRecordValue(page); + if (!record) throw new AgentRunError("schema-invalid", "sessions event response must be an object", { httpStatus: 2 }); + const events = eventPageItems(page); + const selected = options.tail === null ? events : events.slice(-options.tail); + const items = selected.map((event) => summarizeRunEvent(event, options.summaryChars)); + const lastSeq = items.length > 0 ? items[items.length - 1]?.seq ?? null : null; + const runId = stringValue(record.runId) ?? options.runId; + return { + action: `session-${options.kind}-summary`, + sessionId: stringValue(record.sessionId) ?? options.sessionId, + runId, + afterSeq: options.afterSeq, + limit: options.limit, + tail: options.tail, + sourceCount: events.length, + count: items.length, + cursor: stringValue(record.cursor), + lastSeq, + nextAfterSeq: lastSeq, + valuesPrinted: false, + items, + drillDownCommands: sessionEventDrillDownCommands(options.kind, options.sessionId, { afterSeq: options.afterSeq, limit: options.limit, runId }), + }; +} + +export function summarizeQueueDispatchResult(result: JsonValue, taskId: string): JsonRecord { + const record = jsonRecordValue(result); + if (!record) throw new AgentRunError("schema-invalid", "queue dispatch response must be an object", { httpStatus: 2 }); + const task = jsonRecordValue(record.task); + const run = jsonRecordValue(record.run); + const command = jsonRecordValue(record.command); + const runnerJob = jsonRecordValue(record.runnerJob); + const latestAttempt = jsonRecordValue(record.latestAttempt) ?? jsonRecordValue(task?.latestAttempt); + const runId = stringValue(run?.id) ?? stringValue(latestAttempt?.runId); + const commandId = stringValue(command?.id) ?? stringValue(latestAttempt?.commandId); + const sessionId = stringValue(latestAttempt?.sessionId) ?? stringValue(jsonRecordValue(run?.sessionRef)?.sessionId) ?? stringValue(jsonRecordValue(task?.sessionRef)?.sessionId); + return { + action: "queue-dispatch-summary", + taskId: stringValue(task?.id) ?? taskId, + mutation: record.mutation === true, + task: summarizeQueueTaskRecord(task, taskId), + latestAttempt: summarizeAttemptRecord(latestAttempt), + run: summarizeRunRecord(run), + command: summarizeCommandRecord(command), + runnerJob: summarizeRunnerJobRecord(runnerJob), + fullResponseBytes: jsonByteLength(result), + valuesPrinted: false, + pollCommands: { + queue: `./scripts/agentrun queue show ${stringValue(task?.id) ?? taskId}`, + ...(runId ? { run: `./scripts/agentrun runs show ${runId}` } : {}), + ...(runId && commandId ? { command: `./scripts/agentrun commands show ${commandId} --run-id ${runId}` } : {}), + ...(runId ? { events: `./scripts/agentrun runs events ${runId} --after-seq 0 --limit 100 --tail-summary` } : {}), + ...(sessionId ? { trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100` } : {}), + }, + expandedOutput: { + fullFlag: "--full", + rawFlag: "--raw", + note: "For mutating commands, request expanded output on the original invocation.", + }, + }; +} + function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord { const payload = jsonRecordValue(event.payload) ?? {}; const command = boundedSummaryString(stringValue(payload.command), summaryChars); @@ -209,11 +282,14 @@ function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord const message = boundedSummaryString(stringValue(payload.failureMessage) ?? stringValue(payload.message), summaryChars); const summary = text ?? command ?? outputSummary ?? message ?? ""; const summaryTruncated = [command, text, outputSummary, message].some((value) => value?.endsWith("...") === true); + const runnerTrace = findNestedValue(payload, "runnerTrace"); return { seq: numberValue(event.seq) ?? 0, type: stringValue(event.type) ?? "unknown", method: stringValue(payload.method), status: stringValue(payload.status) ?? stringValue(payload.terminalStatus) ?? stringValue(payload.phase), + phase: stringValue(payload.phase), + commandId: stringValue(payload.commandId), command, text, exitCode: numberValue(payload.exitCode), @@ -222,6 +298,11 @@ function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord outputBytes: outputBytesFromPayload(payload), outputSummary, summary, + payloadBytes: jsonByteLength(payload), + payloadKeys: Object.keys(payload).sort().slice(0, 24), + hasRunnerTrace: runnerTrace !== null, + runnerTraceBytes: runnerTrace === null ? 0 : jsonByteLength(runnerTrace), + hasRawEvent: hasNestedKey(payload, ["raw", "rawEvent", "raw_event", "event"]), }; } @@ -310,6 +391,140 @@ function isPlainTextOutput(value: CliResult): value is PlainTextOutput { return typeof value === "object" && value !== null && plainTextOutputMarker in value; } +function wantsExpandedOutput(args: ParsedArgs): boolean { + return args.flags.get("full") === true || args.flags.get("raw") === true; +} + +function summarizeSessionMutationResult(action: "session-cancel" | "session-read", sessionId: string, result: JsonValue, flags: JsonRecord): JsonRecord { + const record = jsonRecordValue(result); + return { + action, + sessionId, + mutation: true, + ...flags, + result: summarizeGenericRecord(record), + fullResponseBytes: jsonByteLength(result), + valuesPrinted: false, + drillDownCommands: { + show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`, + trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, + output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100`, + traceFull: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100 --full`, + outputFull: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100 --full`, + }, + expandedOutput: { + fullFlag: "--full", + rawFlag: "--raw", + note: "For mutating commands, request expanded output on the original invocation.", + }, + }; +} + +function sessionEventDrillDownCommands(kind: "trace" | "output", sessionId: string, options: { afterSeq: number; limit: number; runId: string | null }): JsonRecord { + const base = `./scripts/agentrun sessions ${kind} ${sessionId} --after-seq ${options.afterSeq} --limit ${options.limit}${options.runId ? ` --run-id ${options.runId}` : ""}`; + return { + full: `${base} --full`, + raw: `${base} --raw`, + next: `./scripts/agentrun sessions ${kind} ${sessionId} --after-seq --limit ${options.limit}${options.runId ? ` --run-id ${options.runId}` : ""}`, + }; +} + +function summarizeQueueTaskRecord(record: JsonRecord | null, fallbackTaskId: string): JsonRecord { + return compactRecord(record, { + fallback: { id: fallbackTaskId }, + keys: ["id", "state", "queue", "lane", "title", "priority", "backendProfile", "providerId", "sessionPath", "version", "updatedAt", "cancelledAt", "cancelReason"], + }); +} + +function summarizeAttemptRecord(record: JsonRecord | null): JsonRecord | null { + if (!record) return null; + return compactRecord(record, { keys: ["attemptId", "state", "runId", "commandId", "runnerJobId", "sessionId", "sessionPath"] }); +} + +function summarizeRunRecord(record: JsonRecord | null): JsonRecord | null { + if (!record) return null; + return compactRecord(record, { keys: ["id", "status", "terminalStatus", "failureKind", "failureMessage", "backendProfile", "providerId", "claimedBy", "leaseExpiresAt", "createdAt", "updatedAt"] }); +} + +function summarizeCommandRecord(record: JsonRecord | null): JsonRecord | null { + if (!record) return null; + return compactRecord(record, { keys: ["id", "runId", "seq", "type", "state", "createdAt", "updatedAt", "acknowledgedAt"] }); +} + +function summarizeRunnerJobRecord(record: JsonRecord | null): JsonRecord | null { + if (!record) return null; + const runner = jsonRecordValue(record.runner); + const jobIdentity = jsonRecordValue(record.jobIdentity); + const kubernetes = jsonRecordValue(record.kubernetes); + return { + ...compactRecord(record, { keys: ["action", "mutation", "runId", "commandId", "attemptId", "runnerId", "namespace", "jobName"] }), + logPath: stringValue(runner?.logPath), + backendProfile: stringValue(runner?.backendProfile), + jobUid: stringValue(jobIdentity?.uid), + created: kubernetes?.created === true, + warnings: Array.isArray(record.warnings) ? record.warnings.map((item) => boundedSummaryString(typeof item === "string" ? item : JSON.stringify(item), 240)).filter((item): item is string => Boolean(item)) : [], + fullResponseBytes: jsonByteLength(record), + valuesPrinted: false, + }; +} + +function summarizeGenericRecord(record: JsonRecord | null): JsonRecord | null { + if (!record) return null; + return compactRecord(record, { + keys: ["id", "runId", "commandId", "sessionId", "readerId", "sessionVersion", "taskId", "taskVersion", "status", "state", "terminalStatus", "failureKind", "failureMessage", "updatedAt", "readAt", "cancelledAt", "cancelReason"], + }); +} + +function compactRecord(record: JsonRecord | null, options: { keys: string[]; fallback?: JsonRecord }): JsonRecord { + const result: JsonRecord = { ...(options.fallback ?? {}) }; + if (!record) return result; + for (const key of options.keys) { + const value = record[key]; + if (value === undefined) continue; + if (typeof value === "string") result[key] = boundedSummaryString(value, key.toLowerCase().includes("message") ? 300 : 900) ?? ""; + else if (typeof value === "number" || typeof value === "boolean" || value === null) result[key] = value; + } + result.fullRecordBytes = jsonByteLength(record); + result.valuesPrinted = false; + return result; +} + +function jsonByteLength(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(redactJson(value)) ?? "null", "utf8"); + } catch { + return 0; + } +} + +function findNestedValue(value: unknown, key: string, depth = 0): unknown | null { + if (depth > 8 || value === null || value === undefined) return null; + if (Array.isArray(value)) { + for (const item of value) { + const found = findNestedValue(item, key, depth + 1); + if (found !== null) return found; + } + return null; + } + if (typeof value !== "object") return null; + const record = value as Record; + if (Object.prototype.hasOwnProperty.call(record, key)) return record[key] ?? null; + for (const entry of Object.values(record)) { + const found = findNestedValue(entry, key, depth + 1); + if (found !== null) return found; + } + return null; +} + +function hasNestedKey(value: unknown, keys: readonly string[], depth = 0): boolean { + if (depth > 8 || value === null || value === undefined) return false; + if (Array.isArray(value)) return value.some((item) => hasNestedKey(item, keys, depth + 1)); + if (typeof value !== "object") return false; + const record = value as Record; + if (keys.some((key) => Object.prototype.hasOwnProperty.call(record, key))) return true; + return Object.values(record).some((entry) => hasNestedKey(entry, keys, depth + 1)); +} + async function listSessions(args: ParsedArgs): Promise { const params = new URLSearchParams(); const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null); @@ -328,14 +543,24 @@ async function listSessions(args: ParsedArgs): Promise { async function sessionEvents(args: ParsedArgs, sessionId: string, kind: "trace" | "output"): Promise { const params = new URLSearchParams(); - const afterSeq = optionalFlag(args, "after-seq"); - const limit = optionalFlag(args, "limit"); + const afterSeq = integerFlag(args, "after-seq", 0, { min: 0 }); + const limit = integerFlag(args, "limit", 100, { min: 1, max: 500 }); const runId = optionalFlag(args, "run-id"); - if (afterSeq) params.set("afterSeq", afterSeq); - if (limit) params.set("limit", limit); + params.set("afterSeq", String(afterSeq)); + params.set("limit", String(limit)); if (runId) params.set("runId", runId); const query = params.toString(); - return client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/${kind}${query ? `?${query}` : ""}`); + const page = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/${kind}${query ? `?${query}` : ""}`); + if (wantsExpandedOutput(args)) return page; + return summarizeSessionEventPage(page, { + kind, + sessionId, + afterSeq, + limit, + runId, + tail: tailFlag(args, limit), + summaryChars: integerFlag(args, "summary-chars", 900, { min: 1, max: 4_000 }), + }); } async function sessionCreate(args: ParsedArgs, positionalSessionId: string | null): Promise { @@ -445,7 +670,14 @@ async function sessionSteer(args: ParsedArgs, sessionId: string): Promise { const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/control`, { action: "cancel", ...cancelBody(args) }); - return { action: "session-cancel", sessionId, result }; + if (wantsExpandedOutput(args)) return { action: "session-cancel", sessionId, result: result as JsonValue }; + return summarizeSessionMutationResult("session-cancel", sessionId, result, { cancelled: true }); +} + +async function sessionRead(args: ParsedArgs, sessionId: string): Promise { + const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" }); + if (wantsExpandedOutput(args)) return result; + return summarizeSessionMutationResult("session-read", sessionId, result, { read: true }); } async function submitQueueTask(args: ParsedArgs): Promise { @@ -497,7 +729,9 @@ async function dispatchQueueTask(args: ParsedArgs, taskId: string): Promise { @@ -998,10 +1232,10 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "sessions show [--reader-id ]", "sessions turn [sessionId] --json-file --prompt-file [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-file ]", "sessions steer --prompt-file ", - "sessions cancel [--reason ]", - "sessions trace [--after-seq ] [--limit ] [--run-id ]", - "sessions output [--after-seq ] [--limit ] [--run-id ]", - "sessions read [--reader-id ]", + "sessions cancel [--reason ] [--full|--raw]", + "sessions trace [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--full|--raw]", + "sessions output [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--full|--raw]", + "sessions read [--reader-id ] [--full|--raw]", "commands create --type turn|steer|interrupt --json-file ", "commands show --run-id ", "commands result --run-id ", @@ -1018,7 +1252,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "queue commander [--queue ] [--reader-id ]", "queue read [--reader-id ]", "queue cancel [--reason ]", - "queue dispatch [--json-file ] [--idempotency-key ] [--image ] [--namespace ]", + "queue dispatch [--json-file ] [--idempotency-key ] [--image ] [--namespace ] [--full|--raw]", "queue refresh ", "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|] [--codex-home ] [--model-catalog-file ] [--namespace agentrun-v01] [--secret-name ]", "provider-profiles list", diff --git a/src/selftest/cases/15-cli-events-summary.ts b/src/selftest/cases/15-cli-events-summary.ts index faacc40..b039558 100644 --- a/src/selftest/cases/15-cli-events-summary.ts +++ b/src/selftest/cases/15-cli-events-summary.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import type { JsonRecord } from "../../common/types.js"; import { assertNoSecretLeak, type SelfTestContext, type SelfTestResult } from "../harness.js"; -import { renderRunEventSummaryTsv, summarizeRunEventPage } from "../../../scripts/src/cli.js"; +import { renderRunEventSummaryTsv, summarizeQueueDispatchResult, summarizeRunEventPage, summarizeSessionEventPage } from "../../../scripts/src/cli.js"; export default function selfTest(_context: SelfTestContext): SelfTestResult { const summary = summarizeRunEventPage({ items: [ @@ -30,5 +30,49 @@ export default function selfTest(_context: SelfTestContext): SelfTestResult { assert.equal(tsv.includes("\n12\ttool_call\tshell\tcompleted\t0\t321\ttrue\t4096"), true); assert.equal(tsv.includes("test-token-material"), false); - return { name: "15-cli-events-summary", tests: ["runs-events-summary-tail", "runs-events-summary-tsv-redaction"] }; + const hugeRunnerTrace = "runner trace line with test-token-material\n".repeat(2_000); + const sessionSummary = summarizeSessionEventPage({ + sessionId: "sess_noise", + runId: "run_noise", + items: [{ + seq: 266, + type: "backend_status", + payload: { + phase: "turn/cancelled", + status: "cancelled", + commandId: "cmd_noise", + runnerTrace: { raw: hugeRunnerTrace }, + rawEvent: { nested: hugeRunnerTrace }, + }, + }], + count: 1, + cursor: "266", + }, { kind: "trace", sessionId: "sess_noise", afterSeq: 250, limit: 100, runId: null, tail: null, summaryChars: 80 }); + const sessionItems = sessionSummary.items as JsonRecord[]; + assert.equal(sessionSummary.action, "session-trace-summary"); + assert.equal(sessionSummary.nextAfterSeq, 266); + assert.equal(sessionItems[0]?.hasRunnerTrace, true); + assert.ok(Number(sessionItems[0]?.runnerTraceBytes) > 10_000); + assert.equal(JSON.stringify(sessionSummary).includes("runner trace line"), false); + assert.equal(String((sessionSummary.drillDownCommands as JsonRecord).full).includes("--full"), true); + assert.equal(String((sessionSummary.drillDownCommands as JsonRecord).raw).includes("--raw"), true); + assertNoSecretLeak(sessionSummary); + + const queueSummary = summarizeQueueDispatchResult({ + action: "queue-dispatch", + mutation: true, + task: { id: "qt_noise", state: "running", queue: "dev", lane: "case", title: "large trace queue dispatch", version: 7, payload: { prompt: hugeRunnerTrace } }, + run: { id: "run_noise", status: "running", terminalStatus: null, backendProfile: "codex", providerId: "G14" }, + command: { id: "cmd_noise", runId: "run_noise", seq: 1, type: "turn", state: "acknowledged", payload: { prompt: hugeRunnerTrace } }, + runnerJob: { action: "create-kubernetes-job", runId: "run_noise", commandId: "cmd_noise", attemptId: "attempt_noise", runnerId: "runner_noise", namespace: "agentrun-v01", jobName: "job-noise", runner: { logPath: "kubectl logs job/job-noise", runnerTrace: hugeRunnerTrace } }, + latestAttempt: { attemptId: "attempt_noise", state: "running", runId: "run_noise", commandId: "cmd_noise", runnerJobId: "rj_noise", sessionId: "sess_noise", sessionPath: "/api/v1/sessions/sess_noise" }, + }, "qt_noise"); + assert.equal(queueSummary.action, "queue-dispatch-summary"); + assert.equal(((queueSummary.runnerJob as JsonRecord).attemptId), "attempt_noise"); + assert.equal(JSON.stringify(queueSummary).includes("runner trace line"), false); + assert.equal(String(((queueSummary.pollCommands as JsonRecord).events)).includes("--tail-summary"), true); + assert.equal(((queueSummary.expandedOutput as JsonRecord).fullFlag), "--full"); + assertNoSecretLeak(queueSummary); + + return { name: "15-cli-events-summary", tests: ["runs-events-summary-tail", "runs-events-summary-tsv-redaction", "sessions-events-low-noise-summary", "queue-dispatch-low-noise-summary"] }; }