Merge pull request #1827 from pikasTech/fix/1824-agentrun-event-detail
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix: 增加 AgentRun 单事件有界详情
This commit is contained in:
Lyon
2026-07-12 14:14:24 +08:00
committed by GitHub
8 changed files with 633 additions and 6 deletions
@@ -15,6 +15,7 @@ bun scripts/cli.ts agentrun describe task/<taskId>
bun scripts/cli.ts agentrun describe task/<taskId> --input -o json
bun scripts/cli.ts agentrun describe aipodspec/<name>
bun scripts/cli.ts agentrun events run/<runId> --limit 20
bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq> -o json
bun scripts/cli.ts agentrun logs session/<sessionId> --tail 120
bun scripts/cli.ts agentrun result command/<commandId> --run <runId>
bun scripts/cli.ts agentrun get attempts --task <taskId>
@@ -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 <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 和 fragmentSCP-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 必须遵守以下边界:
+165 -1
View File
@@ -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<Record<string, unknown>> {
});
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<string, unknown>;
counts?: Record<string, unknown>;
initialPrompt?: Record<string, unknown>;
};
redaction?: Record<string, unknown>;
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: `<scp-like-remote;path-omitted;sha256:${SCP_LIKE_REMOTE_SHA256}>`,
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;
+8 -1
View File
@@ -59,6 +59,7 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun describe task/<taskId>",
"bun scripts/cli.ts agentrun describe task/<taskId> --input -o json",
"bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
"bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq>",
"bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
"bun scripts/cli.ts agentrun result run/<runId> --command <commandId>",
"bun scripts/cli.ts agentrun ack task/<taskId> --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/<runId> [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]";
return [
"Usage: bun scripts/cli.ts agentrun events run/<runId> [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]",
" bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq> [-o json|yaml]",
"",
"--detail-seq returns one bounded Secret-safe EventDetail projection. Use the separate --after-seq <seq-1> --limit 1 --full|--raw path only for explicit complete disclosure.",
].join("\n");
}
if (verb === "logs") {
return "Usage: bun scripts/cli.ts agentrun logs session/<sessionId> [--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/<taskId> --input -o json",
" bun scripts/cli.ts agentrun describe run/<runId> --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
" bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq>",
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
" bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
+375
View File
@@ -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<string, unknown>,
options: AgentRunResourceOptions,
runId: string,
item: Record<string, unknown>,
): 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<string, unknown>,
options: Pick<AgentRunResourceOptions, "node" | "lane"> = { node: null, lane: null },
): Record<string, unknown> {
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) : "<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<string, unknown>, payload: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>, payload: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
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<string, unknown>, payload: Record<string, unknown>): {
durableValue: string | null;
durableValueRedacted: boolean;
modeInference: Record<string, unknown> | 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<string, unknown>): Record<string, unknown> {
const keys = [
"phase",
"kind",
"method",
"status",
"failureKind",
"willRetry",
"exitCode",
"durationMs",
"namespace",
"jobName",
"serviceAccountName",
"sourceCommit",
"bootCommit",
"commitId",
"treeId",
];
const result: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const keys = ["command", "stdout", "stderr", "output", "outputSummary", "summary", "text", "message", "reason", "prompt", "content"];
const result: Record<string, unknown> = {};
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<string, unknown> {
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, unknown>): 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<string, unknown>): Record<string, unknown> {
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;
}
+1 -1
View File
@@ -376,7 +376,7 @@ export function renderEventLike(command: string, raw: Record<string, unknown>, 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",
+73 -2
View File
@@ -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 <seq> without --after-seq, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.",
"Use --after-seq <seq-1> --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/<runId>.",
{
details: {
recoveryActions: ["Use: bun scripts/cli.ts agentrun events run/<runId> --detail-seq <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<string, unknown>,
export async function resourceEvents(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise<RenderedCliResult> {
const ref = parseResourceRef(action, args, "run");
const runId = ref.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run <runId>");
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 <item.seq-1> --limit 1 --full -o json${targetArgs.length === 0 ? "" : ` ${targetArgs}`}`;
const detailCommand = `bun scripts/cli.ts agentrun events run/${runId} --detail-seq <item.seq>${targetArgs.length === 0 ? "" : ` ${targetArgs}`}`;
return renderEventLike(command, result, options, "Event", normalizeEventItems({ items: visibleItems }, runId), runId, {
requestedLimit,
effectiveLimit,
+1
View File
@@ -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;