// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. trigger module for scripts/src/agentrun.ts. // Moved mechanically from scripts/src/agentrun.ts:2747-3186 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 { AgentRunPublicExposure } from "./config"; import type { ConfirmOptions, LaneConfirmOptions, SecretSyncOptions, TriggerOptions } from "./options"; import { triggerCurrentYamlLaneConfirmed } from "./cleanup-scripts"; import { readAgentRunClientConfig } from "./config"; import { displayValue } from "./options"; import { pathValue } from "./render"; import { startAsyncAgentRunJob } from "./rest-bridge"; import { collectLaneSecretSources, readSecretSourceValue, restartYamlLaneScript, secretSyncScript } from "./secrets"; import { capture, captureJsonPayload, compactCapture, isGitSha, stringOrNull } from "./utils"; import { yamlLaneK3sSourceStatusScript, yamlLaneSourceBootstrapProbeScript } from "./yaml-lane"; export function renderNextObjectLines(next: Record): string[] { return Object.values(next) .filter((value): value is string => typeof value === "string" && value.length > 0) .slice(0, 5) .map((line) => ` ${line}`); } export function yesNo(value: unknown): string { if (value === true) return "yes"; if (value === false) return "no"; if (value === null || value === undefined) return "-"; return displayValue(value); } export function shortSha(value: unknown): string { const text = displayValue(value); if (/^[0-9a-f]{40}$/u.test(text)) return text.slice(0, 12); return text; } export function compactLaneSecretsStatus(secrets: Record): Record { const items = Array.isArray(secrets.items) ? secrets.items.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; const missing = items .filter((item) => item.present !== true || item.keyPresent !== true) .map((item) => ({ namespace: item.namespace ?? null, name: item.name ?? null, key: item.key ?? null, present: item.present ?? false, keyPresent: item.keyPresent ?? false, })); return { ready: secrets.ready ?? false, count: secrets.count ?? items.length, missingCount: missing.length, missing, valuesPrinted: false, }; } export function compactAgentRunLaneStatusTarget(spec: AgentRunLaneSpec): Record { return { node: { id: spec.nodeId, route: spec.nodeRoute, kubeRoute: spec.nodeKubeRoute, }, lane: spec.lane, version: spec.version, source: { repository: spec.source.repository, branch: spec.source.branch, }, runtime: { namespace: spec.runtime.namespace, managerDeployment: spec.runtime.managerDeployment, managerService: spec.runtime.managerService, }, ci: { namespace: spec.ci.namespace, pipeline: spec.ci.pipeline, pipelineRunPrefix: spec.ci.pipelineRunPrefix, }, gitops: { branch: spec.gitops.branch, argoApplication: spec.gitops.argoApplication, }, gitMirror: { namespace: spec.gitMirror.namespace, readService: spec.gitMirror.readService, writeService: spec.gitMirror.writeService, repositoryCount: spec.gitMirror.repositories.length, }, database: { mode: spec.database.mode, provider: spec.database.provider, configRef: spec.database.configRef, localPostgresExpectedAbsent: spec.database.localPostgresExpectedAbsent, valuesPrinted: false, }, secretCount: spec.secrets.length, valuesPrinted: false, }; } export async function restartYamlLane(config: UniDeskConfig, options: LaneConfirmOptions): Promise> { const { configPath, spec } = resolveAgentRunLaneTarget(options); const plan = { node: spec.nodeId, kubeRoute: spec.nodeKubeRoute, lane: spec.lane, namespace: spec.runtime.namespace, deployment: spec.runtime.managerDeployment, annotation: "kubectl.kubernetes.io/restartedAt", reason: "reload-lane-secrets-or-runtime-env", valuesPrinted: false, }; if (options.dryRun || !options.confirm) { return { ok: true, command: "agentrun control-plane restart", mode: "dry-run", mutation: false, configPath, target: agentRunLaneSummary(spec), plan, next: { confirm: `bun scripts/cli.ts agentrun control-plane restart --node ${spec.nodeId} --lane ${spec.lane} --confirm`, status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; } const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", restartYamlLaneScript(spec)]); const payload = captureJsonPayload(result); return { ok: result.exitCode === 0 && payload.ok !== false, command: "agentrun control-plane restart", mode: "confirmed-rollout-restart", mutation: true, configPath, target: agentRunLaneSummary(spec), plan, result: payload, capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 4000, stderrTailChars: 4000 }), next: { status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; } export async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise> { const { configPath, spec } = resolveAgentRunLaneTarget(options); const sources = collectLaneSecretSources(spec); if (sources.length === 0) { return { ok: false, command: "agentrun control-plane secret-sync", mode: "yaml-declared-node-lane", configPath, target: agentRunLaneSummary(spec), degradedReason: "lane-secret-sources-not-declared", valuesPrinted: false, }; } const values = sources.map((source) => ({ spec: source, value: readSecretSourceValue(spec, source) })); const plan = values.map(({ spec: item, value }) => ({ id: item.id, namespace: item.targetRef.namespace, secret: item.targetRef.name, key: item.targetRef.key, sourceRef: item.sourceRef, sourceMode: item.sourceMode, sourceKey: item.sourceKey, sourcePath: value.redactedPath, fingerprint: value.fingerprint, valueBytes: value.valueBytes, valuesPrinted: false, })); if (options.dryRun || !options.confirm) { return { ok: true, command: "agentrun control-plane secret-sync", mode: "dry-run", mutation: false, configPath, target: agentRunLaneSummary(spec), plan: { secretCount: plan.length, items: plan, valuesPrinted: false }, next: { confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; } const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", secretSyncScript(spec, values.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]); const payload = captureJsonPayload(result); return { ok: result.exitCode === 0 && payload.ok !== false, command: "agentrun control-plane secret-sync", mode: "confirmed-sync", mutation: true, configPath, target: agentRunLaneSummary(spec), plan: { secretCount: plan.length, items: plan, valuesPrinted: false }, result: payload, capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), next: { status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; } export async function exposeAgentRun(_config: UniDeskConfig, options: ConfirmOptions): Promise> { const clientConfig = readAgentRunClientConfig(); const exposure = clientConfig.publicExposure; if (!exposure?.enabled) { return { ok: true, command: "agentrun control-plane expose", action: "disabled-by-yaml", publicExposure: exposureSummary(exposure), valuesPrinted: false, }; } if (options.dryRun) { return { ok: true, command: "agentrun control-plane expose", dryRun: true, mutation: false, publicExposure: exposureSummary(exposure), plan: { masterFrps: { action: "ensure-allow-port", configPath: exposure.masterFrps.configPath, containerName: exposure.masterFrps.containerName, remotePort: exposure.remotePort, }, masterCaddy: { action: "ensure-https-vhost", domain: exposure.masterCaddy.domain, configPath: exposure.masterCaddy.configPath, serviceName: exposure.masterCaddy.serviceName, upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl, responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds, }, }, next: { confirm: "bun scripts/cli.ts agentrun control-plane expose --confirm" }, valuesPrinted: false, }; } const masterFrps = applyAgentRunMasterFrpsAllowPort(exposure); const masterCaddy = applyAgentRunMasterCaddySite(exposure); const ok = masterFrps.ok === true && masterCaddy.ok === true; return { ok, command: "agentrun control-plane expose", mutation: true, publicExposure: exposureSummary(exposure), masterFrps, masterCaddy, validation: { publicProbe: `curl -fsS ${exposure.publicBaseUrl.replace(/\/+$/u, "")}/health/readiness`, directClient: "bun scripts/cli.ts agentrun get tasks --queue commander -o json", valuesPrinted: false, }, valuesPrinted: false, }; } export function exposureSummary(exposure: AgentRunPublicExposure | null | undefined): Record { if (!exposure) return { enabled: false, valuesPrinted: false }; return { enabled: exposure.enabled, proxyName: exposure.proxyName, remotePort: exposure.remotePort, publicBaseUrl: exposure.publicBaseUrl, masterBaseUrl: exposure.masterBaseUrl, frps: { configPath: exposure.masterFrps.configPath, containerName: exposure.masterFrps.containerName, }, caddy: { enabled: exposure.masterCaddy.enabled, domain: exposure.masterCaddy.domain, configPath: exposure.masterCaddy.configPath, serviceName: exposure.masterCaddy.serviceName, upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl, responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds, }, valuesPrinted: false, }; } export function applyAgentRunMasterFrpsAllowPort(exposure: AgentRunPublicExposure): Record { const pathValue = exposure.masterFrps.configPath; const port = exposure.remotePort; const container = exposure.masterFrps.containerName; if (!existsSync(pathValue)) return { ok: false, error: "master-frps-config-missing", path: pathValue, valuesPrinted: false }; const before = readFileSync(pathValue, "utf8"); const alreadyAllowed = frpsAllowPortExists(before, port); let backupPath: string | null = null; let action = "kept-existing"; if (!alreadyAllowed) { const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); backupPath = `${pathValue}.bak-agentrun-${stamp}`; copyFileSync(pathValue, backupPath); writeFileSync(pathValue, `${before.replace(/\s*$/u, "")}\n\n[[allowPorts]]\nstart = ${port}\nend = ${port}\n`, "utf8"); chmodSync(pathValue, statSync(backupPath).mode & 0o777); action = "added-allow-port"; } const restart = alreadyAllowed ? null : spawnSync("docker", ["restart", container], { encoding: "utf8" }); const inspect = spawnSync("docker", ["inspect", "-f", "{{.State.Running}}", container], { encoding: "utf8" }); const ok = alreadyAllowed || (restart?.status === 0 && inspect.status === 0 && String(inspect.stdout).trim() === "true"); return { ok, action, path: pathValue, backupPath, remotePort: port, containerName: container, restart: restart === null ? null : { exitCode: restart.status, stdoutTail: String(restart.stdout).slice(-1000), stderrTail: String(restart.stderr).slice(-1000) }, inspect: { exitCode: inspect.status, running: String(inspect.stdout).trim() === "true", stderrTail: String(inspect.stderr).slice(-1000) }, valuesPrinted: false, }; } export function applyAgentRunMasterCaddySite(exposure: AgentRunPublicExposure): Record { const caddy = exposure.masterCaddy; if (!caddy.enabled) return { ok: true, action: "disabled-by-yaml", valuesPrinted: false }; const pathValue = caddy.configPath; if (!existsSync(pathValue)) return { ok: false, error: "master-caddy-config-missing", path: pathValue, valuesPrinted: false }; const desiredBlock = renderAgentRunCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl, caddy.responseHeaderTimeoutSeconds); const applied = applyLocalCaddyManagedSite({ serviceId: "agentrun", markerName: "agentrun", configPath: pathValue, serviceName: caddy.serviceName, siteBlock: desiredBlock, legacySiteDomain: caddy.domain, }); return { ...applied, domain: caddy.domain, upstreamBaseUrl: caddy.upstreamBaseUrl, responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds, valuesPrinted: false, }; } export function renderAgentRunCaddySiteBlock(domain: string, upstreamBaseUrl: string, responseHeaderTimeoutSeconds: number): string { const upstream = new URL(upstreamBaseUrl); const upstreamHost = `${upstream.hostname}${upstream.port ? `:${upstream.port}` : ""}`; return `${domain} { encode zstd gzip reverse_proxy ${upstreamHost} { header_up Host {host} header_up X-Real-IP {remote_host} transport http { dial_timeout 5s response_header_timeout ${responseHeaderTimeoutSeconds}s } } }`; } export function frpsAllowPortExists(toml: string, port: number): boolean { const sections = toml.split(/(?=\[\[allowPorts\]\])/u); return sections.some((section) => { if (!section.includes("[[allowPorts]]")) return false; const start = section.match(/^\s*start\s*=\s*(\d+)\s*$/mu); const end = section.match(/^\s*end\s*=\s*(\d+)\s*$/mu); return Number(start?.[1]) === port && Number(end?.[1]) === port; }); } export async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): Promise> { return await triggerCurrentYamlLane(config, options, resolveAgentRunLaneTarget(options)); } export async function triggerCurrentYamlLane(config: UniDeskConfig, options: TriggerOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise> { const spec = target.spec; const probe = spec.source.statusMode === "k3s-git-mirror" ? await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)]) : await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapProbeScript(spec)]); const source = captureJsonPayload(probe); const sourceCommit = stringOrNull(source.sourceCommit); const remoteBranchExists = source.remoteBranchExists === true; const pipelineRun = sourceCommit !== null && isGitSha(sourceCommit) ? agentRunPipelineRunName(spec, sourceCommit) : null; const placeholderImage = sourceCommit === null ? null : placeholderAgentRunImage(spec, sourceCommit); const renderedFiles = placeholderImage === null ? [] : renderAgentRunGitopsFiles(spec, { sourceCommit, image: placeholderImage }); const sourcePlan = spec.source.statusMode === "k3s-git-mirror" ? { statusMode: spec.source.statusMode, sourceAuthority: spec.source.sourceAuthority, sourceSnapshot: spec.source.sourceSnapshot, repository: spec.source.repository, branch: spec.source.branch, remoteBranchExists, remoteBranchCommit: stringOrNull(source.remoteBranchCommit), sourceCommit, sourceRef: stringOrNull(source.sourceRef), sourceStageRef: stringOrNull(source.sourceStageRef), snapshotPresent: source.snapshotPresent === true, degradedReason: stringOrNull(source.degradedReason), next: source.snapshotPresent === true ? null : `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm --wait`, valuesPrinted: false, } : { statusMode: spec.source.statusMode, workspace: spec.source.workspace, remote: spec.source.remote, branch: spec.source.branch, bootstrapFromBranch: spec.source.bootstrapFromBranch, bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds, bootstrapPollSeconds: spec.source.bootstrapPollSeconds, remoteBranchExists, sourceCommit, valuesPrinted: false, }; const plan = { node: spec.nodeId, lane: spec.lane, version: spec.version, source: sourcePlan, deploymentFormat: spec.deployment.format, deploymentTruth: "config/agentrun.yaml", removedServiceDeployJson: true, pipelineRun, imageBuild: { repository: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}`, containerfile: spec.deployment.manager.imageBuild.containerfile, buildArgNames: Object.keys(spec.deployment.manager.imageBuild.buildArgs).sort(), timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds, pollSeconds: spec.deployment.manager.imageBuild.pollSeconds, proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null, }, gitops: { branch: spec.gitops.branch, path: spec.gitops.path, renderedFileCount: renderedFiles.length, renderedFilesDigest: renderedFiles.length === 0 ? null : renderedFilesDigest(renderedFiles), }, valuesPrinted: false, }; if (options.dryRun || !options.confirm) { return { ok: true, command: "agentrun control-plane trigger-current", mode: "dry-run", mutation: false, configPath: target.configPath, target: agentRunLaneSummary(spec), plan, sourceProbe: compactCapture(probe, { full: probe.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), next: { confirm: `bun scripts/cli.ts agentrun control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`, status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; } if (options.wait) { return await triggerCurrentYamlLaneConfirmed(config, spec, target.configPath, true); } return startAsyncAgentRunJob( `agentrun_${spec.lane}_trigger_current`, ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", spec.nodeId, "--lane", spec.lane, "--confirm", "--wait"], `Run AgentRun ${spec.version} YAML-only trigger-current on ${spec.nodeId}`, ); }