refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
// 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 { 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 { 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 interface LocalBackendCoreStatus {
|
||||
dockerExecutable: boolean;
|
||||
backendCoreContainer: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
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 localBackendCore = detectLocalBackendCoreStatus();
|
||||
const remoteHost = agentRunBridgeRemoteHost(config, env);
|
||||
const runnerEnv = isAgentRunRunnerEnvironment(env);
|
||||
if (runnerEnv && remoteHost !== null) {
|
||||
return { backend: "remote-frontend-websocket", route: target, reason: "runner-environment", remoteHost, localBackendCore };
|
||||
}
|
||||
if (!localBackendCore.backendCoreContainer && remoteHost !== null) {
|
||||
return { backend: "remote-frontend-websocket", route: target, reason: "local-backend-core-unavailable", remoteHost, localBackendCore };
|
||||
}
|
||||
return { backend: "local-backend-core-broker", route: target, reason: "main-server-local-backend-core", remoteHost: null, localBackendCore };
|
||||
}
|
||||
|
||||
export function detectLocalBackendCoreStatus(): LocalBackendCoreStatus {
|
||||
if (localBackendCoreStatusCache !== null) return localBackendCoreStatusCache;
|
||||
const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout: 2000 });
|
||||
if (result.error !== undefined) {
|
||||
localBackendCoreStatusCache = {
|
||||
dockerExecutable: false,
|
||||
backendCoreContainer: false,
|
||||
error: result.error.message,
|
||||
};
|
||||
return localBackendCoreStatusCache;
|
||||
}
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
localBackendCoreStatusCache = {
|
||||
dockerExecutable: result.status === 0,
|
||||
backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"),
|
||||
error: result.status === 0 ? null : output || `docker ps exited ${result.status ?? "unknown"}`,
|
||||
};
|
||||
return localBackendCoreStatusCache;
|
||||
}
|
||||
|
||||
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-v[0-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"));
|
||||
}
|
||||
Reference in New Issue
Block a user