379 lines
15 KiB
TypeScript
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"));
|
|
}
|