diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index 567a908a..a4b59b7f 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -15,6 +15,7 @@ bun scripts/cli.ts agentrun describe task/ bun scripts/cli.ts agentrun describe task/ --input -o json bun scripts/cli.ts agentrun describe aipodspec/ bun scripts/cli.ts agentrun events run/ --limit 20 +bun scripts/cli.ts agentrun events run/ --detail-seq -o json bun scripts/cli.ts agentrun logs session/ --tail 120 bun scripts/cli.ts agentrun result command/ --run bun scripts/cli.ts agentrun get attempts --task diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index 162e7a93..f4c842be 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -16,7 +16,15 @@ - 默认 human、`-o json` 和 `-o yaml` 最多返回 20 条同构事件摘要,并用一条 lookahead 披露 `hasMore` 与 `nextAfterSeq`; - tool call 只展示 identity、seq/type/status、commandId、工具、命令首行、exit/duration、output bytes/hash、truncation 与 `outputOmitted`; - 默认不得展开 stdout、stderr、skill 正文或完整 payload,也不得通过提高全局 stdout 阈值掩盖 dump; -- 完整事件使用显式 `--full`、`--raw`,或按 `item.identity` 和 `afterSeq=item.seq-1 --limit 1 --full` 下钻; +- 单事件有界详情使用 `--detail-seq `: + - human、JSON 和 YAML 使用同一 `EventDetail` 投影; + - 固定读取同一 run 的 durable event,并校验返回 seq 完全一致; + - 投影保留 identity、correlation、phase-specific evidence、count/bytes/hash/omitted; + - durable `resolutionAuthority` 保持原事实或显式脱敏,基于其他字段的非权威判断只进入独立 inference 字段; + - 标准 Git URL 移除 userinfo、query 和 fragment,SCP-like 与未知 remote 只披露不可逆 fingerprint; + - credential value 和 URL credential 固定脱敏,`valuesPrinted=false`; +- `--detail-seq` 与 `--after-seq`、`--limit`、`--full`、`--raw` 互斥,并在请求 manager 前 fail closed; +- 完整事件继续使用显式 `--full`、`--raw`,或按 `afterSeq=item.seq-1 --limit 1 --full` 下钻; - 该 CLI 只渲染 AgentRun durable facts,不删除事件、不按正文去重,也不改变 Kafka/SSE 事件链。 AipodSpec describe 必须遵守以下边界: diff --git a/scripts/src/agentrun-events.test.ts b/scripts/src/agentrun-events.test.ts index b1c5b555..5d2dfec9 100644 --- a/scripts/src/agentrun-events.test.ts +++ b/scripts/src/agentrun-events.test.ts @@ -8,6 +8,10 @@ const FIXTURE_RUN_ID = "run_event_summary_fixture"; const HIDDEN_OUTPUT_MARKER = "HIDDEN_TOOL_OUTPUT_MUST_NOT_REACH_DEFAULT_RENDER"; const HIDDEN_STDERR_MARKER = "HIDDEN_STDERR_MUST_NOT_REACH_DEFAULT_RENDER"; const HIDDEN_SECOND_COMMAND_LINE = "HIDDEN_SECOND_COMMAND_LINE"; +const HIDDEN_CREDENTIAL_MARKER = "HIDDEN_EVENT_CREDENTIAL_MUST_NOT_REACH_DETAIL"; +const HIDDEN_URL_CREDENTIAL = "HIDDEN_URL_CREDENTIAL_MUST_NOT_REACH_DETAIL"; +const SCP_LIKE_REMOTE = `${HIDDEN_URL_CREDENTIAL}@mirror.example.invalid:pikasTech/unidesk.git?token=${HIDDEN_CREDENTIAL_MARKER}`; +const SCP_LIKE_REMOTE_SHA256 = createHash("sha256").update(SCP_LIKE_REMOTE).digest("hex"); const LARGE_TOOL_OUTPUT = `${HIDDEN_OUTPUT_MARKER}\n${"skill body and command output\n".repeat(2_400)}`; const LARGE_OUTPUT_BYTES = Buffer.byteLength(LARGE_TOOL_OUTPUT, "utf8"); const LARGE_OUTPUT_SHA256 = `sha256:${createHash("sha256").update(LARGE_TOOL_OUTPUT).digest("hex")}`; @@ -75,6 +79,74 @@ function fixtureEvents(): Array> { }); continue; } + if (seq === 42) { + items.push({ + id: `evt_${seq}`, + seq, + type: "backend_status", + payload: { + commandId: "cmd_fixture", + phase: "resource-bundle-materialized", + kind: "gitbundle", + repoUrl: `https://fixture:${HIDDEN_URL_CREDENTIAL}@example.invalid/pikasTech/unidesk.git?access_token=${HIDDEN_CREDENTIAL_MARKER}`, + fetchRepoUrl: SCP_LIKE_REMOTE, + mirrorUsed: true, + commitId: "1".repeat(40), + requestedCommitId: null, + requestedRef: "refs/heads/master", + treeId: "2".repeat(40), + workspaceLifecycle: "first-materialization", + resolutionAuthority: "REDACTED", + primaryWorkspace: { + status: "materialized", + sourceContract: "resourceBundleRef", + repoUrl: `https://fixture:${HIDDEN_URL_CREDENTIAL}@example.invalid/pikasTech/unidesk.git?access_token=${HIDDEN_CREDENTIAL_MARKER}`, + fetchRepoUrl: SCP_LIKE_REMOTE, + mirrorUsed: true, + requestedRef: "refs/heads/master", + requestedCommitId: null, + sourceCommit: "1".repeat(40), + headCommit: "3".repeat(40), + headIsFrozenSource: false, + treeId: "2".repeat(40), + headTreeId: "4".repeat(40), + sourceBranch: "master", + branch: "fix/cadence", + targetPath: ".", + workspaceLifecycle: "continuation-reuse", + resolutionAuthority: "REDACTED", + assemblyContractHash: "sha256:assembly-fixture", + bundleContractHash: "sha256:bundle-contract-fixture", + bundleProvenanceHash: "sha256:bundle-provenance-fixture", + provenanceHash: "sha256:workspace-provenance-fixture", + writable: true, + credential: HIDDEN_CREDENTIAL_MARKER, + valuesPrinted: false, + }, + bundles: { + count: 6, + items: Array.from({ length: 6 }, (_, index) => ({ name: `bundle-${index}`, credential: HIDDEN_CREDENTIAL_MARKER })), + valuesPrinted: false, + }, + tools: { count: 4, items: [{ name: "git", output: LARGE_TOOL_OUTPUT }], valuesPrinted: false }, + skillDirs: { count: 2, items: [{ path: ".agents/skills", body: LARGE_TOOL_OUTPUT }], valuesPrinted: false }, + requiredSkills: { count: 11, items: [{ name: "unidesk-gh", body: LARGE_TOOL_OUTPUT }], valuesPrinted: false }, + promptRefs: { count: 0, items: [], valuesPrinted: false }, + initialPrompt: { + available: true, + bytes: LARGE_OUTPUT_BYTES, + sha256: LARGE_OUTPUT_SHA256, + promptRefCount: 0, + skillCount: 11, + text: LARGE_TOOL_OUTPUT, + valuesPrinted: false, + }, + credentialRef: { name: "fixture-secret", value: HIDDEN_CREDENTIAL_MARKER }, + valuesPrinted: false, + }, + }); + continue; + } items.push({ id: `evt_${seq}`, seq, @@ -186,7 +258,99 @@ describe("AgentRun events render-only progressive disclosure", () => { expect(fullText).toContain(HIDDEN_SECOND_COMMAND_LINE); } - expect(observedLimits).toEqual([21, 21, 21, 100, 100]); + const detailArgs = ["events", `run/${FIXTURE_RUN_ID}`, "--detail-seq", "42"]; + const detailJsonText = renderedText(await runAgentRunCommand(null, [...detailArgs, "-o", "json"])); + const detailJson = JSON.parse(detailJsonText) as { + kind?: string; + source?: string; + seq?: number; + phase?: string; + correlation?: { runId?: string; commandId?: string }; + evidence?: { + primaryWorkspace?: Record; + counts?: Record; + initialPrompt?: Record; + }; + redaction?: Record; + valuesPrinted?: boolean; + }; + expect(detailJson.kind).toBe("EventDetail"); + expect(detailJson.source).toBe(`run/${FIXTURE_RUN_ID}`); + expect(detailJson.seq).toBe(42); + expect(detailJson.phase).toBe("resource-bundle-materialized"); + expect(detailJson.correlation).toMatchObject({ runId: FIXTURE_RUN_ID, commandId: "cmd_fixture" }); + expect(detailJson.evidence?.primaryWorkspace).toMatchObject({ + sourceContract: "resourceBundleRef", + repoUrl: "https://example.invalid/pikasTech/unidesk.git", + fetchRepoUrl: ``, + sourceCommit: "1".repeat(40), + headCommit: "3".repeat(40), + workspaceLifecycle: "continuation-reuse", + resolutionAuthority: null, + resolutionAuthoritySource: "durable-event", + resolutionAuthorityRedacted: true, + resolutionModeInference: { + mode: "runner-first-materialization", + source: "durable-event requestedCommitId", + authoritative: false, + valuesPrinted: false, + }, + writable: true, + assemblyContractHash: "sha256:assembly-fixture", + bundleContractHash: "sha256:bundle-contract-fixture", + bundleProvenanceHash: "sha256:bundle-provenance-fixture", + provenanceHash: "sha256:workspace-provenance-fixture", + valuesPrinted: false, + }); + expect(detailJson.evidence?.counts).toEqual({ bundles: 6, tools: 4, skillDirs: 2, requiredSkills: 11, promptRefs: 0 }); + expect(detailJson.evidence?.initialPrompt).toMatchObject({ + available: true, + bytes: LARGE_OUTPUT_BYTES, + sha256: LARGE_OUTPUT_SHA256, + promptRefCount: 0, + skillCount: 11, + contentOmitted: true, + valuesPrinted: false, + }); + expect(detailJson.redaction).toEqual({ credentialValues: "omitted", urlCredentials: "removed", secretRefsOnly: true, valuesPrinted: false }); + expect(detailJson.valuesPrinted).toBe(false); + expect(Buffer.byteLength(detailJsonText, "utf8")).toBeLessThan(10_240); + + const detailYamlText = renderedText(await runAgentRunCommand(null, [...detailArgs, "-o", "yaml"])); + const detailYaml = Bun.YAML.parse(detailYamlText) as typeof detailJson; + expect(detailYaml.evidence).toEqual(detailJson.evidence); + const detailHumanText = renderedText(await runAgentRunCommand(null, detailArgs)); + expect(detailHumanText).toContain("workspaceLifecycle"); + expect(detailHumanText).toContain("sha256:workspace-provenance-fixture"); + expect(detailHumanText).toContain("--after-seq 41 --limit 1 --full -o json"); + + for (const boundedText of [detailJsonText, detailYamlText, detailHumanText]) { + expect(Buffer.byteLength(boundedText, "utf8")).toBeLessThan(10_240); + expect(boundedText).not.toContain(HIDDEN_OUTPUT_MARKER); + expect(boundedText).not.toContain(HIDDEN_CREDENTIAL_MARKER); + expect(boundedText).not.toContain(HIDDEN_URL_CREDENTIAL); + expect(boundedText).not.toContain("access_token"); + } + + const requestsBeforeInvalid = observedLimits.length; + const invalid = await runAgentRunCommand(null, [...detailArgs, "--full", "-o", "json"]); + expect(invalid.ok).toBe(false); + expect(renderedText(invalid)).toContain("validation-failed"); + expect(renderedText(invalid)).toContain("bounded EventDetail and complete event disclosure are separate read modes"); + expect(observedLimits.length).toBe(requestsBeforeInvalid); + + const wrongVerb = await runAgentRunCommand(null, ["describe", `run/${FIXTURE_RUN_ID}`, "--detail-seq", "42", "-o", "json"]); + expect(wrongVerb.ok).toBe(false); + expect(renderedText(wrongVerb)).toContain("--detail-seq is supported only by agentrun events"); + expect(observedLimits.length).toBe(requestsBeforeInvalid); + + const missing = await runAgentRunCommand(null, ["events", `run/${FIXTURE_RUN_ID}`, "--detail-seq", "48", "-o", "json"]); + expect(missing.ok).toBe(false); + expect(renderedText(missing)).toContain("not-found"); + expect(renderedText(missing)).toContain("no durable event at seq 48"); + + expect(observedLimits.slice(0, 5)).toEqual([21, 21, 21, 100, 100]); + expect(observedLimits.slice(5)).toEqual([1, 1, 1, 1]); } finally { server.stop(true); if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 6913220c..afa071d9 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -59,6 +59,7 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun describe task/", "bun scripts/cli.ts agentrun describe task/ --input -o json", "bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", + "bun scripts/cli.ts agentrun events run/ --detail-seq ", "bun scripts/cli.ts agentrun logs session/ --tail 100", "bun scripts/cli.ts agentrun result run/ --command ", "bun scripts/cli.ts agentrun ack task/ --reader-id cli", @@ -285,7 +286,12 @@ export function agentRunHelpText(args: string[]): string { ].join("\n"); } if (verb === "events") { - return "Usage: bun scripts/cli.ts agentrun events run/ [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]"; + return [ + "Usage: bun scripts/cli.ts agentrun events run/ [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]", + " bun scripts/cli.ts agentrun events run/ --detail-seq [-o json|yaml]", + "", + "--detail-seq returns one bounded Secret-safe EventDetail projection. Use the separate --after-seq --limit 1 --full|--raw path only for explicit complete disclosure.", + ].join("\n"); } if (verb === "logs") { return "Usage: bun scripts/cli.ts agentrun logs session/ [--tail 100|--after-seq N] [--limit 100] [--full-text] [-o json|yaml] [--raw]"; @@ -396,6 +402,7 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun describe task/ --input -o json", " bun scripts/cli.ts agentrun describe run/ --node NC01 --lane nc01-v02", " bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", + " bun scripts/cli.ts agentrun events run/ --detail-seq ", " bun scripts/cli.ts agentrun logs session/ --tail 100", " bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", " bun scripts/cli.ts agentrun get attempts --task --limit 20", diff --git a/scripts/src/agentrun/event-detail.ts b/scripts/src/agentrun/event-detail.ts new file mode 100644 index 00000000..7c15dfe2 --- /dev/null +++ b/scripts/src/agentrun/event-detail.ts @@ -0,0 +1,375 @@ +import { createHash } from "node:crypto"; + +import type { RenderedCliResult } from "../output"; +import { normalizeEventItems, renderMachine, renderedCliResult } from "./render"; +import type { AgentRunResourceOptions } from "./utils"; +import { record, stringOrNull } from "./utils"; + +const EVENT_DETAIL_TEXT_CHARS = 240; +const EVENT_DETAIL_FIELD_LIMIT = 32; + +export function renderEventDetail( + command: string, + raw: Record, + options: AgentRunResourceOptions, + runId: string, + item: Record, +): RenderedCliResult { + const payload = compactEventDetail(runId, item, options); + if (options.output === "json" || options.output === "yaml") { + return renderMachine(command, payload, options.output, raw.ok !== false); + } + return renderedCliResult(raw.ok !== false, command, renderEventDetailHuman(payload)); +} + +export function compactEventDetail( + runId: string, + item: Record, + options: Pick = { node: null, lane: null }, +): Record { + const rawPayload = record(item.payload); + const summary = normalizeEventItems({ items: [item] }, runId)[0] ?? {}; + const seq = typeof summary.seq === "number" ? summary.seq : item.seq; + const targetArgs = [ + options.node === null ? null : `--node ${options.node}`, + options.lane === null ? null : `--lane ${options.lane}`, + ].filter((value): value is string => value !== null).join(" "); + const suffix = targetArgs.length === 0 ? "" : ` ${targetArgs}`; + const afterSeq = typeof seq === "number" && Number.isSafeInteger(seq) ? Math.max(0, seq - 1) : ""; + const evidence = compactEventEvidence(item, rawPayload); + return { + kind: "EventDetail", + source: `run/${runId}`, + identity: summary.identity ?? `run/${runId}/seq/${String(seq)}`, + seq, + type: summary.type ?? null, + phase: summary.phase ?? null, + status: summary.status ?? null, + correlation: compactDefined({ + runId, + commandId: nullableSummaryValue(summary.commandId), + taskId: firstBoundedString(item.taskId, rawPayload.taskId), + attemptId: firstBoundedString(item.attemptId, rawPayload.attemptId), + sessionId: firstBoundedString(item.sessionId, rawPayload.sessionId), + runnerId: firstBoundedString(item.runnerId, rawPayload.runnerId), + runnerJobId: firstBoundedString(item.runnerJobId, rawPayload.runnerJobId), + itemId: firstBoundedString(item.itemId, rawPayload.itemId), + }), + ...(summary.tool === undefined ? {} : { tool: summary.tool }), + ...(summary.exitCode === undefined ? {} : { exitCode: summary.exitCode }), + ...(summary.durationMs === undefined ? {} : { durationMs: summary.durationMs }), + ...(summary.outputBytes === undefined ? {} : { outputBytes: summary.outputBytes }), + ...(summary.outputSha256 === undefined ? {} : { outputSha256: summary.outputSha256 }), + ...(summary.outputTruncated === undefined ? {} : { outputTruncated: summary.outputTruncated }), + ...(summary.outputOmitted === undefined ? {} : { outputOmitted: summary.outputOmitted }), + summary: safeEventDetailSummary(summary, rawPayload), + evidence, + disclosure: { + projection: "bounded render-only durable event detail", + payload: "omitted; selected scalars, counts, bytes and hashes only", + full: `bun scripts/cli.ts agentrun events run/${runId} --after-seq ${afterSeq} --limit 1 --full -o json${suffix}`, + raw: `bun scripts/cli.ts agentrun events run/${runId} --after-seq ${afterSeq} --limit 1 --raw${suffix}`, + }, + redaction: { + credentialValues: "omitted", + urlCredentials: "removed", + secretRefsOnly: true, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +function compactEventEvidence(item: Record, payload: Record): Record { + const phase = firstBoundedString(item.phase, payload.phase); + const generic = { + metadata: compactEventMetadata(payload), + content: compactEventContent(payload), + payloadShape: { + fieldCount: Object.keys(payload).length, + fields: Object.keys(payload).sort().slice(0, EVENT_DETAIL_FIELD_LIMIT).map((key) => boundedText(key, 80)), + omittedFieldCount: Math.max(0, Object.keys(payload).length - EVENT_DETAIL_FIELD_LIMIT), + valuesOmitted: true, + valuesPrinted: false, + }, + }; + if (phase !== "resource-bundle-materialized") return { ...generic, valuesPrinted: false }; + return { + ...generic, + ...compactResourceBundleMaterializedEvidence(payload), + valuesPrinted: false, + }; +} + +function safeEventDetailSummary(summary: Record, payload: Record): string { + const parts = [ + boundedScalar(summary.phase), + boundedScalar(summary.type), + boundedScalar(summary.status), + boundedScalar(summary.tool), + ].filter((value): value is string | number | boolean => value !== null); + for (const [name, value] of [ + ["bundles", payload.bundles], + ["tools", payload.tools], + ["requiredSkills", payload.requiredSkills], + ["promptRefs", payload.promptRefs], + ] as const) { + const count = collectionCount(value); + if (count !== null) parts.push(`${name}=${count}`); + } + return boundedText(parts.map(String).filter((value, index, values) => values.indexOf(value) === index).join("; "), EVENT_DETAIL_TEXT_CHARS); +} + +function compactResourceBundleMaterializedEvidence(payload: Record): Record { + const primary = record(payload.primaryWorkspace); + const source = Object.keys(primary).length > 0 ? primary : payload; + const resolutionAuthority = projectedResolutionAuthority(source, payload); + const primaryWorkspace = { + ...compactDefined({ + status: boundedScalar(source.status), + sourceContract: boundedScalar(source.sourceContract), + repoUrl: redactedUrl(source.repoUrl ?? payload.repoUrl), + fetchRepoUrl: redactedUrl(source.fetchRepoUrl ?? payload.fetchRepoUrl), + mirrorUsed: booleanOrNull(source.mirrorUsed ?? payload.mirrorUsed), + requestedRef: boundedScalar(source.requestedRef ?? payload.requestedRef), + requestedCommitId: boundedScalar(source.requestedCommitId ?? payload.requestedCommitId), + sourceCommit: boundedScalar(source.sourceCommit ?? payload.commitId), + headCommit: boundedScalar(source.headCommit), + headIsFrozenSource: booleanOrNull(source.headIsFrozenSource), + treeId: boundedScalar(source.treeId ?? payload.treeId), + headTreeId: boundedScalar(source.headTreeId), + sourceBranch: boundedScalar(source.sourceBranch), + branch: boundedScalar(source.branch), + targetPath: boundedScalar(source.targetPath), + workspaceLifecycle: boundedScalar(source.workspaceLifecycle ?? payload.workspaceLifecycle), + writable: booleanOrNull(source.writable), + assemblyContractHash: boundedScalar(source.assemblyContractHash), + bundleContractHash: boundedScalar(source.bundleContractHash), + bundleProvenanceHash: boundedScalar(source.bundleProvenanceHash), + provenanceHash: boundedScalar(source.provenanceHash), + }), + resolutionAuthority: resolutionAuthority.durableValue, + resolutionAuthoritySource: "durable-event", + resolutionAuthorityRedacted: resolutionAuthority.durableValueRedacted, + ...(resolutionAuthority.modeInference === null + ? {} + : { resolutionModeInference: resolutionAuthority.modeInference }), + valuesPrinted: false, + }; + const counts = compactDefined({ + bundles: collectionCount(payload.bundles), + tools: collectionCount(payload.tools), + skillDirs: collectionCount(payload.skillDirs), + requiredSkills: collectionCount(payload.requiredSkills), + promptRefs: collectionCount(payload.promptRefs), + }); + const initialPrompt = record(payload.initialPrompt); + const promptSummary = Object.keys(initialPrompt).length === 0 + ? null + : compactDefined({ + available: booleanOrNull(initialPrompt.available), + bytes: finiteNumberOrNull(initialPrompt.bytes), + sha256: boundedScalar(initialPrompt.sha256), + promptRefCount: finiteNumberOrNull(initialPrompt.promptRefCount), + skillCount: finiteNumberOrNull(initialPrompt.skillCount), + contentOmitted: true, + valuesPrinted: false, + }); + return { + primaryWorkspace, + counts, + ...(promptSummary === null ? {} : { initialPrompt: promptSummary }), + }; +} + +function projectedResolutionAuthority(source: Record, payload: Record): { + durableValue: string | null; + durableValueRedacted: boolean; + modeInference: Record | null; +} { + const durable = stringOrNull(source.resolutionAuthority) ?? stringOrNull(payload.resolutionAuthority); + if (durable !== null && durable !== "REDACTED") { + return { + durableValue: boundedText(durable, EVENT_DETAIL_TEXT_CHARS), + durableValueRedacted: false, + modeInference: null, + }; + } + const owner = Object.hasOwn(source, "requestedCommitId") + ? source + : Object.hasOwn(payload, "requestedCommitId") + ? payload + : null; + if (durable === "REDACTED" && owner !== null) { + const requestedCommitId = owner.requestedCommitId; + if (requestedCommitId === null || stringOrNull(requestedCommitId) !== null) { + return { + durableValue: null, + durableValueRedacted: true, + modeInference: { + mode: requestedCommitId === null ? "runner-first-materialization" : "declared-commit", + source: "durable-event requestedCommitId", + authoritative: false, + valuesPrinted: false, + }, + }; + } + } + return { + durableValue: null, + durableValueRedacted: durable === "REDACTED", + modeInference: null, + }; +} + +function compactEventMetadata(payload: Record): Record { + const keys = [ + "phase", + "kind", + "method", + "status", + "failureKind", + "willRetry", + "exitCode", + "durationMs", + "namespace", + "jobName", + "serviceAccountName", + "sourceCommit", + "bootCommit", + "commitId", + "treeId", + ]; + const result: Record = {}; + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string") result[key] = boundedText(value, EVENT_DETAIL_TEXT_CHARS); + else if (typeof value === "number" && Number.isFinite(value)) result[key] = value; + else if (typeof value === "boolean") result[key] = value; + } + result.valuesPrinted = false; + return result; +} + +function compactEventContent(payload: Record): Record { + const keys = ["command", "stdout", "stderr", "output", "outputSummary", "summary", "text", "message", "reason", "prompt", "content"]; + const result: Record = {}; + for (const key of keys) { + if (payload[key] !== undefined && payload[key] !== null) result[key] = contentMetadata(payload[key]); + } + result.valuesPrinted = false; + return result; +} + +function contentMetadata(value: unknown): Record { + const text = typeof value === "string" ? value : stableJson(value); + return { + bytes: Buffer.byteLength(text, "utf8"), + sha256: `sha256:${createHash("sha256").update(text).digest("hex")}`, + omitted: true, + valuesPrinted: false, + }; +} + +function stableJson(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function collectionCount(value: unknown): number | null { + if (Array.isArray(value)) return value.length; + const object = record(value); + const count = finiteNumberOrNull(object.count); + if (count !== null) return count; + return Array.isArray(object.items) ? object.items.length : null; +} + +function redactedUrl(value: unknown): string | null { + const raw = stringOrNull(value); + if (raw === null) return null; + if (raw.includes("://")) { + try { + const parsed = new URL(raw); + if (!["git:", "git+ssh:", "http:", "https:", "ssh:"].includes(parsed.protocol) || parsed.hostname.length === 0) { + return nonstandardRemoteFingerprint("nonstandard-url-remote", raw); + } + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return boundedText(parsed.toString(), EVENT_DETAIL_TEXT_CHARS); + } catch { + return nonstandardRemoteFingerprint("invalid-url", raw); + } + } + const scpLike = /^(?:[^@\s]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:[^\r\n]+$/u.test(raw); + if (scpLike) return nonstandardRemoteFingerprint("scp-like-remote", raw); + return nonstandardRemoteFingerprint("nonstandard-remote", raw); +} + +function nonstandardRemoteFingerprint(kind: string, raw: string): string { + const fingerprint = createHash("sha256").update(raw).digest("hex"); + return `<${kind};path-omitted;sha256:${fingerprint}>`; +} + +function renderEventDetailHuman(payload: Record): string { + const correlation = record(payload.correlation); + const lines = [ + `Event: ${String(payload.identity ?? "-")}`, + `Run: ${String(correlation.runId ?? "-")}`, + `Seq: ${String(payload.seq ?? "-")}`, + `Type: ${String(payload.type ?? "-")}`, + `Phase: ${String(payload.phase ?? "-")}`, + `Status: ${String(payload.status ?? "-")}`, + `Command: ${String(correlation.commandId ?? "-")}`, + `Summary: ${String(payload.summary ?? "-")}`, + "", + "Evidence:", + JSON.stringify(payload.evidence ?? {}, null, 2), + "", + "Disclosure:", + ` Full: ${String(record(payload.disclosure).full ?? "-")}`, + ` Raw: ${String(record(payload.disclosure).raw ?? "-")}`, + " Values: credential and content values omitted", + ]; + return lines.join("\n"); +} + +function compactDefined(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null && item !== undefined)); +} + +function firstBoundedString(...values: unknown[]): string | null { + for (const value of values) { + const text = stringOrNull(value); + if (text !== null) return boundedText(text, EVENT_DETAIL_TEXT_CHARS); + } + return null; +} + +function nullableSummaryValue(value: unknown): unknown { + return value === "-" ? null : value; +} + +function boundedScalar(value: unknown): string | number | boolean | null { + if (typeof value === "string") return boundedText(value, EVENT_DETAIL_TEXT_CHARS); + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean") return value; + return null; +} + +function boundedText(value: string, maxChars: number): string { + const oneLine = value.replace(/\s+/gu, " ").trim(); + return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`; +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 6adc3bfb..3760cee6 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -376,7 +376,7 @@ export function renderEventLike(command: string, raw: Record, o hasMore: pageDisclosure.hasMore, disclosure: { output: "omitted; metadata only", - identity: "item.identity; detail afterSeq=item.seq-1", + identity: "item.identity; detail --detail-seq item.seq", detail: pageDisclosure.detailCommand, full: "--full", raw: "--raw", diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index 7857c791..4fa30e40 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -35,6 +35,7 @@ import { sha256Fingerprint } from "../platform-infra-ops-library"; import type { AgentRunResourceOptions, AgentRunResourceRef, AgentRunResourceVerb } from "./utils"; import { agentRunDryRunPlan } from "./config"; +import { renderEventDetail } from "./event-detail"; import { isHelpArg, renderAgentRunHelp } from "./entry"; import { agentRunExplain, arrayRecords, shortId } from "./options"; import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render"; @@ -61,6 +62,7 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v let options: AgentRunResourceOptions; try { options = parseResourceOptions(resourceArgs); + validateResourceOptionsForVerb(verb, options); } catch (error) { const validationError = error instanceof AgentRunRestError ? error @@ -145,6 +147,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { commandId: null, sessionId: null, afterSeq: null, + eventDetailSeq: null, tail: null, fullText: false, reason: null, @@ -157,7 +160,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { lane: null, passthroughArgs: [], }; - const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane"]); + const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane"]); const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; @@ -197,9 +200,48 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { }, ); } + if (options.eventDetailSeq !== null) { + const conflictingOptions = ["--after-seq", "--limit", "--full", "--raw"] + .filter((flag) => resourceOptionPresent(args, flag)); + if (conflictingOptions.length > 0) { + throw new AgentRunRestError( + "validation-failed", + `--detail-seq cannot be combined with ${conflictingOptions.join(", ")}: bounded EventDetail and complete event disclosure are separate read modes.`, + { + details: { + conflictingOptions: ["--detail-seq", ...conflictingOptions], + recoveryActions: [ + "Use --detail-seq without --after-seq, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.", + "Use --after-seq --limit 1 --full -o json or --raw only when explicitly requesting the complete durable event.", + ], + valuesPrinted: false, + }, + }, + ); + } + } return options; } +function validateResourceOptionsForVerb(verb: AgentRunResourceVerb, options: AgentRunResourceOptions): void { + if (options.eventDetailSeq !== null && verb !== "events") { + throw new AgentRunRestError( + "validation-failed", + "--detail-seq is supported only by agentrun events run/.", + { + details: { + recoveryActions: ["Use: bun scripts/cli.ts agentrun events run/ --detail-seq [-o json|yaml]."], + valuesPrinted: false, + }, + }, + ); + } +} + +function resourceOptionPresent(args: string[], flag: string): boolean { + return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); +} + export function applyResourceOption(options: AgentRunResourceOptions, flag: string, value: string | null): void { if (flag === "-o" || flag === "--output") { if (value !== "human" && value !== "wide" && value !== "name" && value !== "json" && value !== "yaml") throw new Error(`${flag} must be one of human,wide,name,json,yaml`); @@ -224,6 +266,7 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri else if (flag === "--command" || flag === "--command-id") options.commandId = requiredValue(value, flag); else if (flag === "--session" || flag === "--session-id") options.sessionId = requiredValue(value, flag); else if (flag === "--after-seq") options.afterSeq = parseNonNegativeInt(value, "--after-seq", 0, Number.MAX_SAFE_INTEGER); + else if (flag === "--detail-seq") options.eventDetailSeq = parsePositiveInt(value, "--detail-seq"); else if (flag === "--tail") options.tail = parseNonNegativeInt(value, "--tail", 100, 1000); else if (flag === "--reason") options.reason = requiredValue(value, flag); else if (flag === "-f" || flag === "--file" || flag === "--filename") options.file = requiredValue(value, flag); @@ -233,6 +276,13 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri else if (flag === "--lane") options.lane = requiredValue(value, flag); } +function parsePositiveInt(raw: string | null, flag: string): number { + if (raw === null || raw.length === 0) throw new Error(`${flag} requires a value`); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${flag} must be a positive safe integer`); + return value; +} + export function parseNonNegativeInt(raw: string | null, flag: string, defaultValue: number, maxValue: number): number { if (raw === null || raw.length === 0) return defaultValue; const value = Number(raw); @@ -485,6 +535,27 @@ function runnerJobObservationIdentity(managerResource: Record, export async function resourceEvents(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "run"); const runId = ref.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run "); + if (options.eventDetailSeq !== null) { + const detailSeq = options.eventDetailSeq; + const result = await runAgentRunRestCommand(config, "runs", ["events", runId, "--after-seq", String(detailSeq - 1), "--limit", "1"]); + const item = arrayRecords(record(innerData(result)).items ?? innerData(result))[0]; + if (item === undefined || nonNegativeIntegerOrNull(item.seq) !== detailSeq) { + throw new AgentRunRestError( + "not-found", + `run/${runId} has no durable event at seq ${detailSeq}.`, + { + details: { + runId, + requestedSeq: detailSeq, + returnedSeq: item === undefined ? null : nonNegativeIntegerOrNull(item.seq), + recoveryActions: [`List bounded events first: bun scripts/cli.ts agentrun events run/${runId} --after-seq ${Math.max(0, detailSeq - 20)} --limit 20.`], + valuesPrinted: false, + }, + }, + ); + } + return renderEventDetail(command, result, options, runId, item); + } const requestedLimit = Math.max(1, options.limit); const effectiveLimit = options.full || options.raw ? requestedLimit : Math.min(requestedLimit, 20); const requestLimit = options.full || options.raw ? effectiveLimit : Math.min(500, effectiveLimit + 1); @@ -503,7 +574,7 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri options.node === null ? null : `--node ${options.node}`, options.lane === null ? null : `--lane ${options.lane}`, ].filter((value): value is string => value !== null).join(" "); - const detailCommand = `bun scripts/cli.ts agentrun events run/${runId} --after-seq --limit 1 --full -o json${targetArgs.length === 0 ? "" : ` ${targetArgs}`}`; + const detailCommand = `bun scripts/cli.ts agentrun events run/${runId} --detail-seq ${targetArgs.length === 0 ? "" : ` ${targetArgs}`}`; return renderEventLike(command, result, options, "Event", normalizeEventItems({ items: visibleItems }, runId), runId, { requestedLimit, effectiveLimit, diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 8dec3890..12989aef 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -76,6 +76,7 @@ export interface AgentRunResourceOptions { commandId: string | null; sessionId: string | null; afterSeq: number | null; + eventDetailSeq: number | null; tail: number | null; fullText: boolean; reason: string | null;