// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/agentrun.ts. // Moved mechanically from scripts/src/agentrun.ts:1653-2021 for #903. // SPEC: PJ2026-01020108 cancel lifecycle + PJ2026-01020205 AipodSpec binding + PJ2026-01020302 session policy + PJ2026-01020305 cancel control + PJ2026-01060305/06 YAML execution policy and bounded output draft-2026-06-25-p0. // Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI. import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { rootPath, type UniDeskConfig } from "../config"; import type { RenderedCliResult } from "../output"; import { applyLocalCaddyManagedSite } from "../pk01-caddy"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; import { runRemoteSshCommandCapture } from "../remote"; import { startJob } from "../jobs"; import { AGENTRUN_CONFIG_PATH, agentRunLaneSummary, agentRunPipelineRunName, agentRunProviderCredentialRefs, resolveAgentRunLaneTarget, type AgentRunCancelLifecycleSpec, type AgentRunLaneSpec, } from "../agentrun-lanes"; import { agentRunImageArtifact, placeholderAgentRunImage, renderedFilesDigest, renderedObjectsDigest, renderAgentRunControlPlaneManifests, renderAgentRunGitopsFiles, type AgentRunArtifactService, } from "../agentrun-manifests"; import { sha256Fingerprint } from "../platform-infra-ops-library"; import type { AgentRunResourceRef, AgentRunRestTargetOptions } from "./utils"; import { renderAgentRunSessionPolicyExplanation } from "./config"; import { optionValue, parseConfirmOptions, validateOptions } from "./control-plane"; import { firstKnownString, parseResourceKind } from "./render"; import { isAgentRunPipelineRunName, isGitSha, record, stringOrNull } from "./utils"; export function renderFailureLines(value: Record): string[] { const terminalClassification = record(value.terminalClassification); const nestedTerminalClassification = Object.keys(terminalClassification).length > 0 ? terminalClassification : record(record(value.liveness).terminalClassification); const failureKind = stringOrNull(value.failureKind) ?? stringOrNull(nestedTerminalClassification.failureKind) ?? stringOrNull(value.degradedReason); const failureMessage = stringOrNull(value.failureMessage) ?? stringOrNull(nestedTerminalClassification.failureMessage) ?? stringOrNull(value.message) ?? stringOrNull(value.reason); const category = stringOrNull(nestedTerminalClassification.category); const timeoutKind = stringOrNull(nestedTerminalClassification.timeoutKind); const timeoutState = stringOrNull(nestedTerminalClassification.timeoutState); const providerInterruption = stringOrNull(nestedTerminalClassification.providerInterruption); const lastActivity = stringOrNull(nestedTerminalClassification.lastActivityKind); const lastActivitySeq = nestedTerminalClassification.lastActivitySeq; const lines: string[] = []; if (failureKind !== null) lines.push(`Failure: ${failureKind}`); if (failureMessage !== null) lines.push(`Message: ${failureMessage}`); if (category !== null) lines.push(`Category: ${category}`); if (timeoutKind !== null || timeoutState !== null) lines.push(`Timeout: ${displayValue(timeoutKind)} / ${displayValue(timeoutState)}`); if (providerInterruption !== null) lines.push(`Provider: ${providerInterruption}`); if (lastActivity !== null) lines.push(`LastActivity: ${lastActivity}${lastActivitySeq === undefined ? "" : ` seq=${String(lastActivitySeq)}`}`); return lines; } export function renderResourceNextLines(ref: AgentRunResourceRef, value: Record): string[] { const supervisor = record(value.supervisor); const runId = firstKnownString(value.runId, value.activeRunId, value.lastRunId, supervisor.runId, supervisor.activeRunId, supervisor.lastRunId); const commandId = firstKnownString(value.commandId, value.activeCommandId, value.lastCommandId, supervisor.commandId, supervisor.activeCommandId, supervisor.lastCommandId); const runnerJobId = stringOrNull(value.runnerJobId) ?? (ref.kind === "runnerjob" ? ref.name : null); const sessionId = firstKnownString(value.sessionId, supervisor.sessionId); const lines = [ runId === null ? null : `bun scripts/cli.ts agentrun events run/${runId} --after-seq 0 --limit 100`, sessionId === null ? null : `bun scripts/cli.ts agentrun logs session/${sessionId} --tail 100`, runId === null || commandId === null ? null : `bun scripts/cli.ts agentrun result run/${runId} --command ${commandId}`, runId === null || commandId === null ? null : `bun scripts/cli.ts agentrun describe command/${commandId} --run ${runId} --full`, runId === null || runnerJobId === null ? null : `bun scripts/cli.ts agentrun describe runnerjob/${runnerJobId} --run ${runId} --full`, ref.kind !== "session" || sessionId === null ? null : `bun scripts/cli.ts agentrun send session/${sessionId} --prompt-stdin`, ]; const unique: string[] = []; for (const line of lines) { if (line !== null && !unique.includes(line)) unique.push(line); } return unique.slice(0, 5); } export function parseTaskManifest(raw: string, source: string): Record { const parsed = source.endsWith(".json") ? JSON.parse(raw) as unknown : Bun.YAML.parse(raw) as unknown; const input = record(parsed); const kind = stringOrNull(input.kind); if (kind !== null && kind.toLowerCase() !== "task") throw new Error("apply currently supports kind: Task only"); const spec = record(input.spec); const payload = Object.keys(spec).length > 0 ? spec : input; if (!isRecord(payload.payload) && !isRecord(payload.executionPolicy) && stringOrNull(payload.title) === null) { throw new Error("task manifest must contain spec with an AgentRun task payload"); } return payload; } export function nextPagedResourceCommand(command: string, nextAfterSeq: string, limit: number): string { const parts = command .replace(/\s+--after-seq\s+\S+/gu, "") .replace(/\s+--tail(?:\s+\S+)?/gu, "") .replace(/\s+--limit\s+\S+/gu, "") .trim(); return `${parts} --after-seq ${nextAfterSeq} --limit ${limit}`; } export function agentRunExplain(kindRaw: string, args: string[] = [], options: AgentRunRestTargetOptions = { node: null, lane: null }): string { if (kindRaw === "session-policy" || kindRaw === "provider-policy") return renderAgentRunSessionPolicyExplanation(args, options); const kind = parseResourceKind(kindRaw); if (kind === "task") { return [ "KIND: task", "FIELDS:", " metadata.name: optional client name/idempotency hint", " spec.tenantId, spec.projectId, spec.queue, spec.title", " spec.payload.prompt or spec.payload.", " spec.executionPolicy, spec.resourceBundleRef, spec.workspaceRef", "", "Apply example:", " kind: Task", " spec:", " tenantId: unidesk", " projectId: pikasTech/unidesk", " queue: commander", " title: Example", " payload:", " prompt: Do the work", ].join("\n"); } return `KIND: ${kindRaw}\nUse get/describe with -o json to inspect the current stable UniDesk resource schema.`; } export function arrayRecords(value: unknown): Record[] { if (Array.isArray(value)) return value.map(record).filter((item) => Object.keys(item).length > 0); if (typeof value === "object" && value !== null && Array.isArray((value as { items?: unknown }).items)) return arrayRecords((value as { items: unknown }).items); return []; } export function renderTable(headers: string[], rows: string[][]): string { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); const line = (values: string[]): string => values.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ").trimEnd(); return [line(headers), ...rows.map(line)].join("\n"); } export function resourceName(item: Record): string { const kind = String(item.kind ?? "resource"); return `${kind}/${shortId(displayValue(item.name))}`; } export function shortId(value: string): string { if (value === "-" || value.length <= 18) return value; const known = ["qt_", "run_", "cmd_", "rjob_"]; const prefix = known.find((item) => value.startsWith(item)); if (prefix !== undefined) return `${prefix}${value.slice(prefix.length, prefix.length + 8)}...`; if (/^\w+-\d+/u.test(value)) return value.length > 28 ? `${value.slice(0, 25)}...` : value; return `${value.slice(0, 14)}...`; } export function displayValue(value: unknown): string { if (value === undefined || value === null || value === "") return "-"; return String(value); } export function stringOrDash(value: unknown): string { return displayValue(value); } export function truncateOneLine(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))}...`; } export function truncateMultiline(value: string, maxChars: number): string { return value.length <= maxChars ? value : `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars; rerun with --full-text or --full]`; } export function relativeAge(iso: string | null): string { if (iso === null) return "-"; const ms = Date.now() - Date.parse(iso); if (!Number.isFinite(ms) || ms < 0) return "-"; const minutes = Math.floor(ms / 60000); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 48) return `${hours}h`; return `${Math.floor(hours / 24)}d`; } export function pickCompact(value: Record, keys: string[]): Record { const picked: Record = {}; for (const key of keys) { if (value[key] !== undefined) picked[key] = value[key]; } return picked; } export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export interface TriggerOptions extends DisclosureOptions { confirm: boolean; dryRun: boolean; node: string | null; lane: string | null; wait: boolean; } export interface ConfirmOptions { confirm: boolean; dryRun: boolean; } export interface SecretSyncOptions extends ConfirmOptions { node: string | null; lane: string | null; } export interface LaneConfirmOptions extends ConfirmOptions { node: string | null; lane: string | null; } export interface RefreshOptions extends ConfirmOptions, DisclosureOptions { node: string | null; lane: string | null; } export interface GitMirrorStatusOptions extends DisclosureOptions { node: string | null; lane: string | null; } export interface GitMirrorOptions extends ConfirmOptions { node: string | null; lane: string | null; timeoutSeconds: number; wait: boolean; } export interface CleanupRunnersOptions extends ConfirmOptions, DisclosureOptions { node: string | null; lane: string | null; timeoutSeconds: number; forceActive: boolean; } export interface CleanupRunsOptions extends ConfirmOptions { node: string | null; lane: string | null; minAgeMinutes: number; limit: number; timeoutSeconds: number; } export interface CleanupReleasedPvOptions extends ConfirmOptions { node: string | null; lane: string | null; limit: number; timeoutSeconds: number; } export interface DisclosureOptions { full: boolean; raw: boolean; } export interface StatusOptions extends DisclosureOptions { node: string | null; lane: string | null; sourceCommit: string | null; pipelineRun: string | null; targetMode: "latest-source-head" | "source-commit" | "pipeline-run"; } export interface TimedValue { value: T; elapsedMs: number; } export function parseDisclosureOptions(args: string[]): DisclosureOptions { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--node" || arg === "--lane") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); index += 1; continue; } if (arg !== "--full" && arg !== "--raw") throw new Error(`unsupported status option: ${arg}`); } const raw = args.includes("--raw"); return { full: raw || args.includes("--full"), raw }; } export function parseGitMirrorStatusOptions(args: string[]): GitMirrorStatusOptions { validateOptions(args, new Set(["--full", "--raw"]), new Set(["--node", "--lane"])); const raw = args.includes("--raw"); return { full: raw || args.includes("--full"), raw, node: optionValue(args, "--node") ?? null, lane: optionValue(args, "--lane") ?? null, }; } export function parseStatusOptions(args: string[]): StatusOptions { let node: string | null = null; let lane: string | null = null; let sourceCommit: string | null = null; let pipelineRun: string | null = null; let full = false; let raw = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--full") { full = true; continue; } if (arg === "--raw") { raw = true; full = true; continue; } if (arg === "--node") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); node = value; index += 1; continue; } if (arg === "--lane") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); lane = value; index += 1; continue; } if (arg === "--source-commit") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--source-commit requires a value"); if (!isGitSha(value)) throw new Error("--source-commit must be a full 40-character git SHA"); sourceCommit = value; index += 1; continue; } if (arg === "--pipeline-run") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--pipeline-run requires a value"); if (!isAgentRunPipelineRunName(value)) throw new Error("--pipeline-run must be an agentrun-vNN-ci-<12+ hex> PipelineRun name"); pipelineRun = value; index += 1; continue; } throw new Error(`unsupported status option: ${arg}`); } if (sourceCommit !== null && pipelineRun !== null) throw new Error("control-plane status accepts only one of --source-commit or --pipeline-run"); return { full, raw, node, lane, sourceCommit, pipelineRun, targetMode: pipelineRun !== null ? "pipeline-run" : sourceCommit !== null ? "source-commit" : "latest-source-head", }; } export function parseTriggerOptions(args: string[]): TriggerOptions { const base = parseConfirmOptions(args); let node: string | null = null; let lane: string | null = null; let wait = false; let full = false; let raw = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm" || arg === "--dry-run") continue; if (arg === "--full") { full = true; continue; } if (arg === "--raw") { raw = true; full = true; continue; } if (arg === "--wait") { wait = true; continue; } if (arg === "--node") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); node = value; index += 1; continue; } if (arg === "--lane") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); lane = value; index += 1; continue; } throw new Error(`unsupported trigger-current option: ${arg}`); } return { ...base, node, lane, wait, full, raw }; }