Files
pikasTech-unidesk/scripts/src/agentrun/utils.ts
T
2026-06-29 14:56:01 +00:00

379 lines
15 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. utils module for scripts/src/agentrun.ts.
// Moved mechanically from scripts/src/agentrun.ts:7651-8200 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 { rootPath, type UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { applyLocalCaddyManagedSite } from "../pk01-caddy";
import { runSshCommandCapture, sshCaptureBackendPlan, type SshCaptureBackendPlan, 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 { TimedValue } from "./options";
import { status } from "./control-plane";
import { displayValue, truncateOneLine } from "./options";
import { renderedCliResult } from "./render";
export interface AgentRunResolvedAuth {
source: "env" | "file";
value: string;
}
export type AgentRunHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type AgentRunFailureKind = "auth-missing" | "auth-failed" | "agentrun-connect-failed" | "agentrun-proxy-exec-failed" | "agentrun-manager-fetch-failed" | "agentrun-timeout" | "schema-mismatch" | "unsupported-version" | "validation-failed" | "not-found";
export type AgentRunRestCompatGroup = "queue" | "sessions" | "aipod-specs" | "aipods" | "runs" | "commands" | "runner";
export type AgentRunResourceVerb = "get" | "describe" | "events" | "logs" | "result" | "ack" | "cancel" | "dispatch" | "create" | "apply" | "send" | "explain";
export type AgentRunResourceKind = "task" | "run" | "command" | "runnerjob" | "session" | "aipodspec";
export type AgentRunOutputMode = "human" | "wide" | "name" | "json" | "yaml";
export interface AgentRunResourceRef {
kind: AgentRunResourceKind;
name: string;
}
export interface AgentRunResourceOptions {
output: AgentRunOutputMode;
full: boolean;
raw: boolean;
debug: boolean;
limit: number;
queue: string | null;
state: string | null;
unread: boolean;
readerId: string;
taskId: string | null;
runId: string | null;
commandId: string | null;
sessionId: string | null;
afterSeq: number | null;
tail: number | null;
fullText: boolean;
reason: string | null;
dryRun: boolean;
file: string | null;
aipod: string | null;
idempotencyKey: string | null;
promptStdin: boolean;
node: string | null;
lane: string | null;
passthroughArgs: string[];
}
export interface AgentRunRestTargetOptions {
node: string | null;
lane: string | null;
}
export interface AgentRunRestCompatOptions extends AgentRunRestTargetOptions {
output: AgentRunOutputMode;
full: boolean;
raw: boolean;
}
export interface AgentRunRestTarget {
config: UniDeskConfig;
configPath: string;
spec: AgentRunLaneSpec;
}
export interface AgentRunSessionPolicyTarget {
configPath: string;
spec: AgentRunLaneSpec;
source: "selected-lane" | "default-lane";
transport: "direct-http" | "lane-k8s-service-proxy";
}
export type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket";
export type LocalBackendCoreStatus = SshCaptureBackendPlan["localBackendCore"];
export interface AgentRunBridgeCapturePlan {
backend: AgentRunBridgeCaptureBackend;
route: string;
reason: string;
remoteHost: string | null;
localBackendCore: LocalBackendCoreStatus;
}
export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?: AgentRunBridgeCapturePlan };
export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null;
export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
const plan = agentRunBridgeCapturePlan(config, target);
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
return await captureRemote(config, plan, target, args);
}
const local = attachBridgeExecution(await runSshCommandCapture(config, target, args), plan);
const fallbackHost = agentRunBridgeRemoteHost(config);
if (local.exitCode !== 0 && fallbackHost !== null && bridgeExecutionFailureKind(local)?.degradedReason === "capture-backend-unavailable") {
const fallbackPlan: AgentRunBridgeCapturePlan = {
...plan,
backend: "remote-frontend-websocket",
remoteHost: fallbackHost,
reason: "local-capture-backend-unavailable",
};
return await captureRemote(config, fallbackPlan, target, args);
}
return local;
}
export async function captureRemote(config: UniDeskConfig, plan: AgentRunBridgeCapturePlan, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
if (plan.remoteHost === null) return attachBridgeExecution(remoteBridgeCaptureFailure(new Error("remote host is not configured")), plan);
try {
return attachBridgeExecution(await runRemoteSshCommandCapture(config, plan.remoteHost, target, args), plan);
} catch (error) {
return attachBridgeExecution(remoteBridgeCaptureFailure(error), plan);
}
}
export function remoteBridgeCaptureFailure(error: unknown): SshCaptureResult {
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
return {
exitCode: 255,
stdout: "",
stderr: `unidesk remote frontend ssh bridge failed: ${message}\n`,
};
}
export function attachBridgeExecution(result: SshCaptureResult, plan: AgentRunBridgeCapturePlan): AgentRunBridgeCaptureResult {
return { ...result, bridgeExecution: plan };
}
export function agentRunBridgeCapturePlan(config: UniDeskConfig, target: string, env: NodeJS.ProcessEnv = process.env): AgentRunBridgeCapturePlan {
const plan = sshCaptureBackendPlan(config, env);
return {
backend: plan.backend,
route: target,
reason: plan.reason,
remoteHost: plan.remoteHost,
localBackendCore: plan.localBackendCore,
};
}
export function isAgentRunRunnerEnvironment(env: NodeJS.ProcessEnv): boolean {
return Boolean(
env.AGENTRUN_BOOT_MODE
|| env.AGENTRUN_RUN_ID
|| env.AGENTRUN_K8S_JOB_NAME
|| env.CODE_QUEUE_SERVICE_ROLE
|| env.CODE_QUEUE_INSTANCE_ID
|| env.KUBERNETES_SERVICE_HOST,
);
}
export function agentRunBridgeRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): string | null {
return normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_IP)
?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_HOST)
?? normalizeRemoteHostHint(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
?? normalizeRemoteHostHint(config.network.publicHost);
}
export function normalizeRemoteHostHint(raw: string | undefined): string | null {
const value = raw?.trim() ?? "";
if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null;
return value.replace(/\/+$/u, "");
}
export function captureBridgeMetadata(result: SshCaptureResult): Record<string, unknown> | null {
const bridgeExecution = (result as AgentRunBridgeCaptureResult).bridgeExecution;
if (bridgeExecution === undefined) return null;
return {
backend: bridgeExecution.backend,
route: bridgeExecution.route,
reason: bridgeExecution.reason,
remoteHost: bridgeExecution.remoteHost,
localBackendCore: bridgeExecution.localBackendCore,
};
}
export function bridgeExecutionFailureKind(result: SshCaptureResult): { degradedReason: string; failureKind: string; recoveryActions: string[] } | null {
if (result.exitCode === 0) return null;
const stderr = result.stderr;
if (/No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(stderr)) {
return {
degradedReason: "capture-backend-unavailable",
failureKind: "bridge-execution-environment",
recoveryActions: [
"Run the same command from a healthy UniDesk main-server CLI, or provide UNIDESK_MAIN_SERVER_IP/UNIDESK_MAIN_SERVER_HOST so the bridge can use the frontend WebSocket SSH backend.",
"For AgentRun runner jobs, request the unidesk-ssh tool credential SecretRef instead of embedding secret values in prompts or payloads.",
"After restoring the bridge backend, retry the same bun scripts/cli.ts agentrun ... command; do not use direct kubectl or GitHub API bypasses.",
],
};
}
if (/frontend login failed|remote frontend ssh bridge|websocket error|timed out waiting for provider session|route-not-allowed/iu.test(stderr)) {
return {
degradedReason: "bridge-execution-environment-unavailable",
failureKind: "bridge-execution-environment",
recoveryActions: [
"Verify the UniDesk frontend/backend-core SSH bridge is reachable from this runner and that UNIDESK_MAIN_SERVER_IP or UNIDESK_MAIN_SERVER_HOST points at the main server.",
"Verify scoped UNIDESK_SSH_CLIENT_TOKEN route allowlist includes the requested AgentRun route, without printing token values.",
"Retry with the same bun scripts/cli.ts agentrun ... command after the controlled bridge path is restored.",
],
};
}
return null;
}
export function compactCapture(result: SshCaptureResult, options: { full?: boolean; stdoutTailChars?: number; stderrTailChars?: number } = {}): Record<string, unknown> {
const stdoutTailChars = options.stdoutTailChars ?? 8000;
const stderrTailChars = options.stderrTailChars ?? 4000;
const full = options.full ?? true;
const payload: Record<string, unknown> = {
exitCode: result.exitCode,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTailOmitted: !full && result.exitCode === 0,
stderrTailOmitted: !full && result.exitCode === 0,
};
const bridgeExecution = captureBridgeMetadata(result);
if (bridgeExecution !== null) payload.bridgeExecution = bridgeExecution;
const failureKind = bridgeExecutionFailureKind(result);
if (failureKind !== null) {
payload.degradedReason = failureKind.degradedReason;
payload.failureKind = failureKind.failureKind;
payload.recoveryActions = failureKind.recoveryActions;
}
if (full || result.exitCode !== 0) {
payload.stdoutTail = tail(result.stdout, stdoutTailChars);
payload.stderrTail = tail(result.stderr, stderrTailChars);
payload.stdoutTruncated = result.stdout.length > stdoutTailChars;
payload.stderrTruncated = result.stderr.length > stderrTailChars;
}
return payload;
}
export async function timedStatusStage<T>(stage: string, action: () => Promise<T>): Promise<TimedValue<T>> {
const startedAtMs = Date.now();
progressEvent("agentrun.control-plane.status.progress", { stage, status: "started" });
try {
const value = await action();
const exitCode = isCaptureResult(value) ? value.exitCode : typeof record(value).ok === "boolean" && record(value).ok !== true ? 1 : 0;
progressEvent("agentrun.control-plane.status.progress", {
stage,
status: exitCode === 0 ? "succeeded" : "failed",
exitCode,
elapsedMs: Date.now() - startedAtMs,
});
return { value, elapsedMs: Date.now() - startedAtMs };
} catch (error) {
progressEvent("agentrun.control-plane.status.progress", {
stage,
status: "failed",
elapsedMs: Date.now() - startedAtMs,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
export function progressEvent(event: string, payload: Record<string, unknown>): void {
const stage = displayValue(payload.stage ?? event);
const status = displayValue(payload.status ?? "-");
const details = [
payload.exitCode === undefined ? null : `exit=${displayValue(payload.exitCode)}`,
payload.elapsedMs === undefined ? null : `elapsed=${displayValue(payload.elapsedMs)}ms`,
payload.error === undefined ? null : `error=${truncateOneLine(displayValue(payload.error), 160)}`,
].filter((item): item is string => item !== null);
process.stderr.write(`status ${stage} ${status}${details.length === 0 ? "" : ` ${details.join(" ")}`}\n`);
}
export function isCaptureResult(value: unknown): value is SshCaptureResult {
return typeof value === "object" && value !== null && "exitCode" in value && typeof (value as { exitCode?: unknown }).exitCode === "number";
}
export function isAgentRunPipelineRunName(value: string): boolean {
return /^agentrun-[a-z0-9-]*ci-[0-9a-f]{12,40}(?:-[a-z0-9-]+)?$/u.test(value);
}
export function captureJsonPayload(result: SshCaptureResult): Record<string, unknown> {
const trimmed = result.stdout.trim();
if (trimmed.length === 0) return {};
try {
return record(JSON.parse(trimmed) as unknown);
} catch {
const lastJsonLine = trimmed.split(/\r?\n/u).reverse().find((line) => line.trim().startsWith("{") && line.trim().endsWith("}"));
if (lastJsonLine === undefined) return {};
try {
return record(JSON.parse(lastJsonLine) as unknown);
} catch {
return {};
}
}
}
export function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
export function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function nonNegativeIntegerOrNull(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
if (typeof value === "string" && /^\d+$/u.test(value)) return Number(value);
return null;
}
export function isGitSha(value: string): boolean {
return /^[0-9a-f]{40}$/u.test(value);
}
export function tail(text: string, maxChars: number): string {
return text.length > maxChars ? text.slice(-maxChars) : text;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function shQuote(value: string): string {
return `'${value.replace(/'/gu, "'\\''")}'`;
}
export function unsupported(args: string[]): RenderedCliResult {
const command = `agentrun ${args.join(" ")}`.trim();
return renderedCliResult(false, command, [
`Error: unsupported AgentRun command: ${command}`,
"",
"Supported resource commands:",
" agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send",
"",
"Compatibility bridge groups:",
" agentrun aipod-specs|queue|runs|commands|runner|sessions",
"",
"Operations:",
" agentrun control-plane status|trigger-current|refresh",
" agentrun git-mirror status|sync|flush",
].join("\n"));
}