Merge pull request #1827 from pikasTech/fix/1824-agentrun-event-detail
fix: 增加 AgentRun 单事件有界详情
This commit is contained in:
@@ -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 和 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 必须遵守以下边界:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user