diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index 9cf15c59..b55604f2 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -45,6 +45,12 @@ bun scripts/cli.ts agentrun create task \ - 未显式传 `--idempotency-key` 时,由 Target/repo/ref/MDTODO/verified commit 派生稳定键; - 幂等重试遇到已有 running 或 terminal task 时,返回既有 attempt/run/session,不重复 dispatch; - 最终输出必须包含 task、attempt、run、command、runner 和 session identity。 +- Target live 的默认 human、`-o json` 和 `-o yaml` 使用同一份有界投影: + - 保留 task、attempt、run、command、runner、session 的 identity 与 state; + - 保留自动派单决策、Target/MDTODO 摘要、Aipod 与 SecretRef presence 摘要; + - `Next` 使用本次派单的具体 identity,并继承 Target 与 lane; + - 不展开完整 task、prompt、payload 或 Aipod 对象; + - 需要完整服务端对象时显式使用 `--raw`。 Artificer 内部执行边界: diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index b60d277b..647db52f 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -1,6 +1,13 @@ import { readFileSync, rmSync } from "node:fs"; import { describe, expect, test } from "bun:test"; -import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun"; +import { + parseAgentRunClientConfigYaml, + parseResourceOptions, + renderMutationSummary, + resolveAgentRunAuth, + runAgentRunCommand, + stripAgentRunResourceWrapperArgs, +} from "./agentrun"; import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; import { agentRunToolCredentialRefs } from "./agentrun/config"; import { autoDispatchTargetTask, targetTaskDispatchDryRunPlan } from "./agentrun/rest-bridge"; @@ -318,6 +325,200 @@ describe("Artificer Target one-command dispatch", () => { rmSync(tempConfigPath, { force: true }); } }); + + test("keeps Target live human, JSON, and YAML output bounded while retaining dispatch identities", () => { + const outputConfig = Bun.YAML.parse( + readFileSync(new URL("../../config/unidesk-cli.yaml", import.meta.url), "utf8"), + ) as { output?: { maxStdoutBytes?: number } }; + const maxStdoutBytes = outputConfig.output?.maxStdoutBytes; + if (typeof maxStdoutBytes !== "number") throw new Error("expected YAML-first output.maxStdoutBytes"); + const hugePrompt = "PROMPT_SHOULD_NOT_BE_RENDERED_".repeat(500); + expect(Buffer.byteLength(hugePrompt, "utf8")).toBeGreaterThan(maxStdoutBytes); + + const identity = { + taskId: "qt_compact_target", + taskState: "running", + attemptId: "qat_compact_target", + runId: "run_compact_target", + commandId: "cmd_compact_target", + runnerJobId: "rjob_compact_target", + sessionId: "sess_compact_target", + valuesPrinted: false, + }; + const autoDispatch = { + enabled: true, + decision: "dispatched", + mutation: true, + duplicateDispatchPrevented: false, + identity, + valuesPrinted: false, + }; + const raw = { + ok: true, + action: "queue-submit-auto-dispatch", + data: { + ...identity, + attemptState: "running", + runState: "running", + commandState: "running", + runnerState: "claimed", + sessionState: "active", + mutation: true, + autoDispatch, + task: { + id: identity.taskId, + state: identity.taskState, + prompt: hugePrompt, + payload: { prompt: hugePrompt }, + latestAttempt: { + attemptId: identity.attemptId, + state: "running", + runnerJobId: identity.runnerJobId, + runnerState: "claimed", + sessionId: identity.sessionId, + }, + }, + run: { + id: identity.runId, + state: "running", + sessionRef: { sessionId: identity.sessionId, state: "active" }, + }, + command: { id: identity.commandId, state: "running", payload: { prompt: hugePrompt } }, + latestAttempt: { + attemptId: identity.attemptId, + state: "running", + runId: identity.runId, + commandId: identity.commandId, + runnerJobId: identity.runnerJobId, + runnerState: "claimed", + sessionId: identity.sessionId, + }, + session: { id: identity.sessionId, state: "active", prompt: hugePrompt }, + valuesPrinted: false, + }, + targetTask: { + mdtodoId: "R6.4", + target: "NC01", + targetWorkspace: "/root/.worktrees/unidesk/artificer-compact-output", + targetRoute: "NC01:/root/.worktrees/unidesk/artificer-compact-output", + projectId: "pikasTech/unidesk", + repository: "pikasTech/unidesk", + ref: "fix/artificer-compact-output", + verifiedCommit: "a".repeat(40), + refRelation: "branch-and-head-match-remote-ref", + workspace: { + branch: "fix/artificer-compact-output", + headCommit: "a".repeat(40), + clean: true, + }, + prompt: hugePrompt, + }, + aipodBinding: { + aipod: "Artificer", + node: "NC01", + lane: "nc01-v02", + namespace: "agentrun-v01", + projectId: "pikasTech/unidesk", + providerId: "NC01", + backendProfile: "sub2api", + workspaceRef: { kind: "opaque", path: ".", valid: true }, + resourceBundleRef: { + kind: "gitbundle", + repoUrl: "https://credential-must-be-redacted@github.com/pikasTech/unidesk.git", + ref: "fix/artificer-compact-output", + inheritedFromAipod: true, + credentialRefPresent: true, + }, + session: { sessionId: identity.sessionId, identitySource: "task" }, + providerCredentials: [{ + profile: "sub2api", + secretRef: { namespace: "agentrun-v01", name: "agentrun-provider", keys: ["auth.json"] }, + bindingPresent: true, + value: hugePrompt, + }], + toolCredentials: [{ + tool: "github", + purpose: "pull-request", + secretRef: { namespace: "agentrun-v01", name: "agentrun-github", keys: ["GH_TOKEN"] }, + bindingPresent: true, + value: hugePrompt, + }], + prompt: hugePrompt, + }, + autoDispatch, + valuesPrinted: false, + }; + expect(Buffer.byteLength(JSON.stringify(raw), "utf8")).toBeGreaterThan(maxStdoutBytes); + + const variants = [ + { name: "human", args: [] }, + { name: "json", args: ["-o", "json"] }, + { name: "yaml", args: ["-o", "yaml"] }, + ]; + for (const variant of variants) { + const rendered = renderMutationSummary( + "agentrun queue submit", + raw, + parseResourceOptions(variant.args), + "Queue task submitted", + ); + const text = renderedTextOf(rendered); + expect(Buffer.byteLength(text, "utf8"), variant.name).toBeLessThan(maxStdoutBytes); + expect(text).not.toContain("PROMPT_SHOULD_NOT_BE_RENDERED_"); + expect(text).not.toContain("credential-must-be-redacted"); + for (const value of Object.values(identity).filter((item): item is string => typeof item === "string")) { + expect(text, `${variant.name}:${value}`).toContain(value); + } + expect(text).toContain("R6.4"); + expect(text).toContain("dispatched"); + } + + const jsonText = renderedTextOf(renderMutationSummary( + "agentrun queue submit", + raw, + parseResourceOptions(["-o", "json"]), + "Queue task submitted", + )); + const json = JSON.parse(jsonText) as Record; + const jsonData = json.data as Record; + const jsonNext = json.next as Record; + const jsonCredentials = (json.aipodBinding as Record).credentialRefs as Record; + expect(jsonData).toMatchObject({ + taskId: identity.taskId, + taskState: "running", + attemptId: identity.attemptId, + attemptState: "running", + runId: identity.runId, + runState: "running", + commandId: identity.commandId, + commandState: "running", + runnerJobId: identity.runnerJobId, + runnerState: "claimed", + sessionId: identity.sessionId, + sessionState: "active", + }); + expect((json.autoDispatch as Record).decision).toBe("dispatched"); + expect((json.targetTask as Record).mdtodoId).toBe("R6.4"); + expect(jsonCredentials).toMatchObject({ totalCount: 2, allPresent: true }); + expect((jsonCredentials.provider as Record).presentCount).toBe(1); + expect((jsonCredentials.tool as Record).presentCount).toBe(1); + expect(jsonNext.result).toBe( + `bun scripts/cli.ts agentrun result run/${identity.runId} --command ${identity.commandId} --target NC01 --lane nc01-v02`, + ); + expect(jsonNext.logs).toBe( + `bun scripts/cli.ts agentrun logs session/${identity.sessionId} --tail 120 --target NC01 --lane nc01-v02`, + ); + + const yamlText = renderedTextOf(renderMutationSummary( + "agentrun queue submit", + raw, + parseResourceOptions(["-o", "yaml"]), + "Queue task submitted", + )); + const yaml = Bun.YAML.parse(yamlText) as Record; + expect(yaml.data).toMatchObject(json.data as object); + expect(yaml.next).toMatchObject(json.next as object); + }); }); describe("AgentRun default transport contract", () => { diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 4f5ca58c..452c641c 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -39,6 +39,7 @@ import { status } from "./control-plane"; import { arrayRecords, displayValue, isRecord, nextPagedResourceCommand, parseTaskManifest, pickCompact, relativeAge, renderFailureLines, renderResourceNextLines, renderTable, resourceName, shortId, stringOrDash, truncateMultiline, truncateOneLine } from "./options"; import { activeAgentRunRestTarget, agentRunRestRequest, runAgentRunRestCommand } from "./rest-bridge"; import { renderSessionSendResult } from "./session-send-render"; +import { compactTargetTaskMutationPayload } from "./target-task-output"; import { record, stringOrNull } from "./utils"; export function resolveAgentRunCancelPolicyTarget(config: UniDeskConfig | null, options: AgentRunResourceOptions): { configPath: string; spec: AgentRunLaneSpec; source: "selected-lane" | "default-lane" } | null { @@ -479,7 +480,10 @@ export function renderResultSummary(command: string, raw: Record, options: AgentRunResourceOptions, headline: string, overrideNextLines?: string[]): RenderedCliResult { if (options.raw) return renderMachine(command, raw, "json", raw.ok !== false); - if (options.output === "json" || options.output === "yaml") return renderMachine(command, raw, options.output, raw.ok !== false); + const targetTaskPayload = compactTargetTaskMutationPayload(raw); + if (options.output === "json" || options.output === "yaml") { + return renderMachine(command, targetTaskPayload ?? raw, options.output, raw.ok !== false); + } const data = record(innerData(raw)); const id = stringOrNull(data.id) ?? stringOrNull(data.taskId) ?? stringOrNull(data.sessionId); const lines = [ @@ -496,12 +500,13 @@ export function renderMutationSummary(command: string, raw: Record line.length > 0).slice(0, 5); + const next = record(targetTaskPayload?.next ?? raw.next ?? data.next); + const nextLimit = targetTaskPayload === null ? 5 : 8; + const nextLines = (overrideNextLines ?? Object.values(next).map(String)).filter((line) => line.length > 0).slice(0, nextLimit); if (nextLines.length > 0) lines.push("", "Next:", ...nextLines.map((line) => ` ${line}`)); return renderedCliResult(raw.ok !== false, command, lines.join("\n")); } @@ -525,8 +530,11 @@ export function renderAipodBindingMutationLines(binding: Record const workspaceRef = record(binding.workspaceRef); const resourceBundleRef = record(binding.resourceBundleRef); const session = record(binding.session); - const providerCredentials = arrayRecords(binding.providerCredentials); - const toolCredentials = arrayRecords(binding.toolCredentials); + const credentialRefs = record(binding.credentialRefs); + const providerSummary = record(credentialRefs.provider); + const toolSummary = record(credentialRefs.tool); + const providerCredentials = arrayRecords(binding.providerCredentials ?? providerSummary.items); + const toolCredentials = arrayRecords(binding.toolCredentials ?? toolSummary.items); const lines = [ "", "AipodBinding:", @@ -535,6 +543,16 @@ export function renderAipodBindingMutationLines(binding: Record ` ResourceBundle: ${displayValue(resourceBundleRef.repoUrl)} ref=${displayValue(resourceBundleRef.ref)} inherited=${displayValue(resourceBundleRef.inheritedFromAipod)}`, ` Session: ${displayValue(session.sessionId)} source=${displayValue(session.identitySource)}`, ]; + if (Object.keys(credentialRefs).length > 0) { + const providerPresence = `${displayValue(providerSummary.presentCount)}/${displayValue(providerSummary.count)}`; + const toolPresence = `${displayValue(toolSummary.presentCount)}/${displayValue(toolSummary.count)}`; + lines.push([ + ` SecretRefs: total=${displayValue(credentialRefs.totalCount)}`, + `allPresent=${displayValue(credentialRefs.allPresent)}`, + `provider=${providerPresence}`, + `tool=${toolPresence}`, + ].join(" ")); + } for (const credential of providerCredentials.slice(0, 4)) { const secretRef = record(credential.secretRef); lines.push(` ProviderSecretRef: ${displayValue(secretRef.namespace)}/${displayValue(secretRef.name)} keys=${displayValue(secretRef.keys)} present=${displayValue(credential.bindingPresent)}`); @@ -553,9 +571,12 @@ export function renderAutoDispatchMutationLines(autoDispatch: Record): Record | null { + const data = asRecord(innerData(raw)); + const targetTask = firstRecord(data.targetTask, raw.targetTask); + const aipodBinding = firstRecord(data.aipodBinding, raw.aipodBinding); + const autoDispatch = firstRecord(data.autoDispatch, raw.autoDispatch); + const decision = boundedText(autoDispatch.decision, 80); + if (Object.keys(targetTask).length === 0 || !isTargetDispatchDecision(decision)) return null; + + const identity = compactTargetTaskIdentity(data, autoDispatch); + const targetSummary = compactTargetTaskSummary(targetTask); + const aipodSummary = compactTargetAipodBinding(aipodBinding); + const autoDispatchSummary = { + enabled: autoDispatch.enabled === true, + decision, + mutation: booleanOrNull(autoDispatch.mutation), + duplicateDispatchPrevented: booleanOrNull(autoDispatch.duplicateDispatchPrevented), + identity, + valuesPrinted: false, + }; + const next = targetTaskIdentityNext(identity, targetSummary, aipodSummary); + + return { + ok: raw.ok !== false, + action: boundedText(raw.action, 120) ?? "queue-submit-auto-dispatch", + data: { + ...identity, + state: identity.taskState, + mutation: booleanOrNull(data.mutation) ?? autoDispatchSummary.mutation, + autoDispatch: autoDispatchSummary, + valuesPrinted: false, + }, + targetTask: targetSummary, + aipodBinding: aipodSummary, + autoDispatch: autoDispatchSummary, + next, + valuesPrinted: false, + }; +} + +function compactTargetTaskIdentity( + data: Record, + autoDispatch: Record, +): Record { + const declared = asRecord(autoDispatch.identity); + const task = asRecord(data.task); + const attempt = firstRecord(data.latestAttempt, task.latestAttempt); + const run = asRecord(data.run); + const command = asRecord(data.command); + const runner = firstRecord(data.runner, data.runnerJob); + const session = firstRecord(data.session, run.sessionRef, task.sessionRef); + return { + taskId: firstText(200, declared.taskId, data.taskId, task.id), + taskState: firstText(80, declared.taskState, data.taskState, task.state, task.status), + attemptId: firstText(200, declared.attemptId, data.attemptId, attempt.attemptId, attempt.id), + attemptState: firstText(80, data.attemptState, attempt.state, attempt.status), + runId: firstText(200, declared.runId, data.runId, run.id), + runState: firstText(80, data.runState, run.state, run.status), + commandId: firstText(200, declared.commandId, data.commandId, command.id), + commandState: firstText(80, data.commandState, command.state, command.status), + runnerJobId: firstText(200, declared.runnerJobId, data.runnerJobId, attempt.runnerJobId, runner.id), + runnerState: firstText(80, data.runnerState, attempt.runnerState, runner.state, runner.status), + sessionId: firstText(240, declared.sessionId, data.sessionId, attempt.sessionId, session.sessionId, session.id), + sessionState: firstText(80, data.sessionState, session.state, session.status), + valuesPrinted: false, + }; +} + +function compactTargetTaskSummary(targetTask: Record): Record { + const workspace = asRecord(targetTask.workspace); + return { + mdtodoId: boundedText(targetTask.mdtodoId, 80), + target: boundedText(targetTask.target, 120), + targetWorkspace: boundedText(targetTask.targetWorkspace, 360), + targetRoute: boundedText(targetTask.targetRoute, 480), + projectId: boundedText(targetTask.projectId, 240), + repository: boundedText(targetTask.repository, 240), + ref: boundedText(targetTask.ref, 240), + verifiedCommit: boundedText(targetTask.verifiedCommit, 160), + refRelation: boundedText(targetTask.refRelation, 120), + workspace: { + branch: boundedText(workspace.branch, 240), + headCommit: boundedText(workspace.headCommit, 160), + clean: booleanOrNull(workspace.clean), + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +function compactTargetAipodBinding(binding: Record): Record { + const workspaceRef = asRecord(binding.workspaceRef); + const resourceBundleRef = asRecord(binding.resourceBundleRef); + const session = asRecord(binding.session); + const provider = compactCredentialPresence(binding.providerCredentials, "provider", 4); + const tool = compactCredentialPresence(binding.toolCredentials, "tool", 6); + return { + aipod: boundedText(binding.aipod, 160), + node: boundedText(binding.node, 120), + lane: boundedText(binding.lane, 120), + namespace: boundedText(binding.namespace, 160), + projectId: boundedText(binding.projectId, 240), + providerId: boundedText(binding.providerId, 160), + backendProfile: boundedText(binding.backendProfile, 160), + workspaceRef: { + kind: boundedText(workspaceRef.kind, 80), + path: boundedText(workspaceRef.path, 360), + valid: booleanOrNull(workspaceRef.valid), + valuesPrinted: false, + }, + resourceBundleRef: { + kind: boundedText(resourceBundleRef.kind, 80), + repoUrl: boundedRepositoryUrl(resourceBundleRef.repoUrl), + ref: boundedText(resourceBundleRef.ref, 240), + inheritedFromAipod: booleanOrNull(resourceBundleRef.inheritedFromAipod), + credentialRefPresent: booleanOrNull(resourceBundleRef.credentialRefPresent), + valuesPrinted: false, + }, + session: { + sessionId: boundedText(session.sessionId, 240), + identitySource: boundedText(session.identitySource, 120), + valuesPrinted: false, + }, + credentialRefs: { + totalCount: provider.count + tool.count, + allPresent: provider.missingCount + tool.missingCount === 0, + provider, + tool, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +function compactCredentialPresence(value: unknown, kind: "provider" | "tool", limit: number): { + count: number; + presentCount: number; + missingCount: number; + omittedCount: number; + items: Record[]; + valuesPrinted: false; +} { + const source = asRecords(value); + const presentCount = source.filter((item) => item.bindingPresent === true).length; + const items = source.slice(0, limit).map((item) => { + const secretRef = asRecord(item.secretRef); + return { + ...(kind === "provider" + ? { profile: boundedText(item.profile, 160) } + : { + tool: boundedText(item.tool, 160), + purpose: boundedText(item.purpose, 200), + }), + secretRef: { + namespace: boundedText(secretRef.namespace, 160), + name: boundedText(secretRef.name, 200), + keys: boundedStrings(secretRef.keys, 6, 80), + valuesPrinted: false, + }, + bindingPresent: item.bindingPresent === true, + valuesPrinted: false, + }; + }); + return { + count: source.length, + presentCount, + missingCount: source.length - presentCount, + omittedCount: source.length - items.length, + items, + valuesPrinted: false, + }; +} + +function targetTaskIdentityNext( + identity: Record, + targetTask: Record, + aipodBinding: Record, +): Record { + const taskId = asString(identity.taskId); + const runId = asString(identity.runId); + const commandId = asString(identity.commandId); + const runnerJobId = asString(identity.runnerJobId); + const sessionId = asString(identity.sessionId); + const target = asString(targetTask.target) ?? asString(aipodBinding.node); + const lane = asString(aipodBinding.lane); + const suffix = [ + target === null ? null : `--target ${target}`, + lane === null ? null : `--lane ${lane}`, + ].filter((value): value is string => value !== null).join(" "); + const scoped = (command: string): string => suffix.length === 0 ? command : `${command} ${suffix}`; + return { + ...(taskId === null ? {} : { + task: scoped(`bun scripts/cli.ts agentrun describe task/${taskId}`), + attempts: scoped(`bun scripts/cli.ts agentrun get attempts --task ${taskId}`), + }), + ...(runId === null ? {} : { + events: scoped(`bun scripts/cli.ts agentrun events run/${runId} --after-seq 0 --limit 20`), + }), + ...(runId === null || commandId === null ? {} : { + result: scoped(`bun scripts/cli.ts agentrun result run/${runId} --command ${commandId}`), + }), + ...(runnerJobId === null || runId === null ? {} : { + runner: scoped(`bun scripts/cli.ts agentrun describe runnerjob/${runnerJobId} --run ${runId}`), + }), + ...(sessionId === null ? {} : { + logs: scoped(`bun scripts/cli.ts agentrun logs session/${sessionId} --tail 120`), + }), + }; +} + +function innerData(value: Record): unknown { + return value.data ?? value; +} + +function firstRecord(...values: unknown[]): Record { + for (const value of values) { + const item = asRecord(value); + if (Object.keys(item).length > 0) return item; + } + return {}; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function asRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.map(asRecord).filter((item) => Object.keys(item).length > 0) : []; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function firstText(maxChars: number, ...values: unknown[]): string | null { + for (const value of values) { + const text = boundedText(value, maxChars); + if (text !== null) return text; + } + return null; +} + +function boundedText(value: unknown, maxChars: number): string | null { + const text = asString(value); + if (text === null) return null; + const oneLine = text.replace(/\s+/gu, " ").trim(); + return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function boundedStrings(value: unknown, limit: number, maxChars: number): string[] { + return Array.isArray(value) + ? value.slice(0, limit).map((item) => boundedText(item, maxChars)).filter((item): item is string => item !== null) + : []; +} + +function boundedRepositoryUrl(value: unknown): string | null { + const text = asString(value); + if (text === null) return null; + return boundedText(text.replace(/^(https?:\/\/)[^@/]+@/u, "$1[redacted]@"), 320); +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function isTargetDispatchDecision(value: string | null): value is TargetDispatchDecision { + return value === "dispatched" || value === "idempotent-replay" || value === "concurrent-replay"; +}