Files
pikasTech-unidesk/scripts/src/agentrun/options.ts
T
2026-06-25 16:16:25 +00:00

415 lines
16 KiB
TypeScript

// 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, unknown>): 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, unknown>): 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<string, unknown> {
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.<structured fields>",
" 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<string, unknown>[] {
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, unknown>): 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<string, unknown>, keys: string[]): Record<string, unknown> {
const picked: Record<string, unknown> = {};
for (const key of keys) {
if (value[key] !== undefined) picked[key] = value[key];
}
return picked;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
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<T> {
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 };
}