415 lines
16 KiB
TypeScript
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 };
|
|
}
|