diff --git a/config/agentrun.yaml b/config/agentrun.yaml index b6af043c..d8d93b84 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -1,3 +1,8 @@ +version: 1 +kind: AgentRunConfig +metadata: + name: agentrun + manager: baseUrl: https://agentrun.74-48-78-17.nip.io/ timeoutMs: 15000 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index ae01431e..eff44e4b 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -1,3 +1,12 @@ +version: 1 +kind: HwlabNodeLaneConfig +metadata: + name: hwlab-node-lanes + +defaults: + node: G14 + lane: v03 + requiredNoProxy: - hyueapi.com - .hyueapi.com diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 7c5258c3..a295b74b 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -193,6 +193,10 @@ export function resolveAgentRunLaneTarget(options: { node?: string | null; lane? return { configPath: config.sourcePath, spec }; } +export function agentRunDefaultProviderId(env: NodeJS.ProcessEnv = process.env): string { + return resolveAgentRunLaneTarget({}, env).spec.nodeId; +} + export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record { return { node: { @@ -314,6 +318,7 @@ export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: st function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig { const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH); const root = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, configPath); + validateConfigEnvelope(root, configPath); const controlPlane = recordField(root, "controlPlane", configPath); const defaultTarget = recordField(controlPlane, "default", `${configPath}.controlPlane`); const nodes = parseNodes(recordField(controlPlane, "nodes", `${configPath}.controlPlane`), configPath); @@ -329,6 +334,15 @@ function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControl }; } +function validateConfigEnvelope(root: Record, configPath: string): void { + if (root.version !== 1) throw new Error(`${configPath}.version must be 1`); + if (root.kind !== "AgentRunConfig") throw new Error(`${configPath}.kind must be AgentRunConfig`); + const metadata = recordField(root, "metadata", configPath); + if (stringField(metadata, "name", `${configPath}.metadata`) !== "agentrun") { + throw new Error(`${configPath}.metadata.name must be agentrun`); + } +} + function parseNodes(input: Record, configPath: string): Record { const result: Record = {}; for (const [id, raw] of Object.entries(input)) { diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index 6b237ae1..4fda85c9 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -10,6 +10,7 @@ import { runRemoteSshCommandCapture } from "./remote"; import { startJob } from "./jobs"; import { AGENTRUN_CONFIG_PATH, + agentRunDefaultProviderId, agentRunLaneSummary, agentRunPipelineRunName, resolveAgentRunLaneTarget, @@ -5302,6 +5303,7 @@ function isExplicitSessionSendBody(input: Record): boolean { async function sessionRunBodyFromArgs(sessionId: string, args: string[], input: Record): Promise> { const existing = await fetchAgentRunSessionOrNull(sessionId, args); + const defaultProviderId = agentRunDefaultProviderId(); const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile") ?? stringOrNull(input.backendProfile) @@ -5310,7 +5312,7 @@ async function sessionRunBodyFromArgs(sessionId: string, args: string[], input: const body: Record = { ...input }; body.tenantId = agentRunOption(args, "tenant-id") ?? stringOrNull(body.tenantId) ?? stringOrNull(existing?.tenantId) ?? "unidesk"; body.projectId = agentRunOption(args, "project-id") ?? stringOrNull(body.projectId) ?? stringOrNull(existing?.projectId) ?? "default"; - body.providerId = agentRunOption(args, "provider-id") ?? stringOrNull(body.providerId) ?? "G14"; + body.providerId = agentRunOption(args, "provider-id") ?? stringOrNull(body.providerId) ?? stringOrNull(existing?.providerId) ?? defaultProviderId; body.backendProfile = profile; body.workspaceRef = jsonObjectOption(args, "workspace-json") ?? record(body.workspaceRef); if (Object.keys(record(body.workspaceRef)).length === 0) body.workspaceRef = { kind: "opaque", path: "." }; @@ -5338,10 +5340,11 @@ async function sessionSendWithAipodRest(sessionId: string, aipod: string, args: const metadata = record(sessionRef.metadata); const title = agentRunOption(args, "title") ?? stringOrNull(task.title); if (title) metadata.title = title; + const defaultProviderId = agentRunDefaultProviderId(); const runBody: Record = { tenantId: task.tenantId, projectId: task.projectId, - providerId: task.providerId ?? "G14", + providerId: task.providerId ?? defaultProviderId, backendProfile: task.backendProfile, workspaceRef: task.workspaceRef ?? { kind: "opaque", path: "." }, sessionRef: { ...sessionRef, sessionId, metadata }, @@ -5489,6 +5492,7 @@ function readAgentRunClientConfig(env: NodeJS.ProcessEnv = process.env): AgentRu export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/agentrun.yaml"): AgentRunClientConfig { const input = record(Bun.YAML.parse(raw) as unknown); + validateAgentRunConfigEnvelope(input, sourcePath); const manager = record(input.manager); const auth = record(input.auth); const client = record(input.client); @@ -5529,6 +5533,13 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/ }; } +function validateAgentRunConfigEnvelope(input: Record, sourcePath: string): void { + if (input.version !== 1) throw new Error(`${sourcePath}: version must be 1`); + if (input.kind !== "AgentRunConfig") throw new Error(`${sourcePath}: kind must be AgentRunConfig`); + const metadata = record(input.metadata); + if (stringFieldFromRecord(metadata, "name", "metadata") !== "agentrun") throw new Error(`${sourcePath}: metadata.name must be agentrun`); +} + function readAgentRunPublicExposureConfig(value: unknown, managerBaseUrl: string, sourcePath: string): AgentRunPublicExposure | null { if (value === undefined || value === null) return null; const exposure = record(value); diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 6bcdbf1d..5b8ccbb7 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -216,6 +216,10 @@ interface HwlabLaneConfig { } interface HwlabNodeLaneConfig { + readonly defaultTarget: { + readonly node: string; + readonly lane: HwlabRuntimeLane; + }; readonly requiredNoProxy: readonly string[]; readonly nodes: Record; readonly lanes: Record; @@ -572,6 +576,8 @@ function readHwlabNodeLaneConfig(): HwlabNodeLaneConfig { const path = rootPath(HWLAB_NODE_LANE_CONFIG_PATH); const raw = readFileSync(path, "utf8"); const parsed = asRecord(Bun.YAML.parse(raw) as unknown, HWLAB_NODE_LANE_CONFIG_PATH); + validateConfigEnvelope(parsed); + const defaultTarget = parseDefaultTarget(asRecord(parsed.defaults, `${HWLAB_NODE_LANE_CONFIG_PATH}.defaults`)); const requiredNoProxy = stringArrayField(parsed, "requiredNoProxy", HWLAB_NODE_LANE_CONFIG_PATH); for (const required of ["hyueapi.com", ".hyueapi.com"]) { if (!requiredNoProxy.includes(required)) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.requiredNoProxy must include ${required}`); @@ -603,7 +609,30 @@ function readHwlabNodeLaneConfig(): HwlabNodeLaneConfig { for (const lane of [...Object.values(lanes), ...Object.values(laneTargets).flatMap((targets) => Object.values(targets))]) { if (nodes[lane.node] === undefined) throw new Error(`lanes.${lane.id}.node references missing node ${lane.node}`); } - return { requiredNoProxy, nodes, lanes, laneTargets, networkProfiles, downloadProfiles }; + if (nodes[defaultTarget.node] === undefined) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.defaults.node references missing node ${defaultTarget.node}`); + const defaultLane = laneTargets[defaultTarget.lane]?.[defaultTarget.node] ?? lanes[defaultTarget.lane]; + if (defaultLane === undefined || defaultLane.node !== defaultTarget.node) { + throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.defaults must reference a declared lane target`); + } + return { defaultTarget, requiredNoProxy, nodes, lanes, laneTargets, networkProfiles, downloadProfiles }; +} + +function validateConfigEnvelope(parsed: Record): void { + if (parsed.version !== 1) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.version must be 1`); + if (parsed.kind !== "HwlabNodeLaneConfig") throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.kind must be HwlabNodeLaneConfig`); + const metadata = asRecord(parsed.metadata, `${HWLAB_NODE_LANE_CONFIG_PATH}.metadata`); + if (stringField(metadata, "name", `${HWLAB_NODE_LANE_CONFIG_PATH}.metadata`) !== "hwlab-node-lanes") { + throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.metadata.name must be hwlab-node-lanes`); + } +} + +function parseDefaultTarget(raw: Record): { readonly node: string; readonly lane: HwlabRuntimeLane } { + const lane = stringField(raw, "lane", `${HWLAB_NODE_LANE_CONFIG_PATH}.defaults`); + if (!isSupportedLaneId(lane)) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.defaults.lane is not supported by this CLI build`); + return { + node: stringField(raw, "node", `${HWLAB_NODE_LANE_CONFIG_PATH}.defaults`), + lane, + }; } const HWLAB_NODE_LANE_CONFIG = readHwlabNodeLaneConfig(); @@ -689,6 +718,10 @@ export function hwlabRuntimeNodeIds(): string[] { return Object.keys(HWLAB_NODE_LANE_CONFIG.nodes); } +export function hwlabDefaultRuntimeTarget(): { readonly node: string; readonly lane: HwlabRuntimeLane } { + return { ...HWLAB_NODE_LANE_CONFIG.defaultTarget }; +} + export function hwlabRuntimeLaneConfigPath(): string { return HWLAB_NODE_LANE_CONFIG_PATH; } diff --git a/scripts/src/jobs.ts b/scripts/src/jobs.ts index 84dc3df0..b8d885ed 100644 --- a/scripts/src/jobs.ts +++ b/scripts/src/jobs.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy import { join } from "node:path"; import { repoRoot, rootPath } from "./config"; import { runCommandToFiles, tailFile } from "./command"; +import { hwlabDefaultRuntimeTarget } from "./hwlab-node-lanes"; export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "canceled"; @@ -438,6 +439,8 @@ function summarizeRuntimeLaneTriggerJobProgress(job: JobRecord, stdoutTail: stri stageElapsedSeconds, lastEventAgeSeconds, }); + const nextStatusCommand = hwlabRuntimeLaneStatusNextCommand(pipelineRun, lastEvent); + if (nextStatusCommand.warning !== null) warnings.push(nextStatusCommand.warning); const tcpPoolDiagnostics = job.status === "succeeded" ? null : sshTcpPoolDiagnosticsFromJobText(`${stderrTail}\n${stdoutTail}`); const slow = warnings.length > 0; return { @@ -469,13 +472,32 @@ function summarizeRuntimeLaneTriggerJobProgress(job: JobRecord, stdoutTail: stri slow ? "visibility-warning" : null, ].filter(Boolean).join(" "), nextCommand: pipelineRun - ? `bun scripts/cli.ts hwlab nodes control-plane status --node ${stringField(lastEvent.node) ?? "G14"} --lane ${stringField(lastEvent.lane) ?? "v03"} --pipeline-run ${pipelineRun}` + ? nextStatusCommand.command : job.status === "running" ? `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000` : null, }; } +function hwlabRuntimeLaneStatusNextCommand(pipelineRun: string | null, lastEvent: Record): { command: string | null; warning: string | null } { + if (pipelineRun === null) return { command: null, warning: null }; + const eventNode = stringField(lastEvent.node); + const eventLane = stringField(lastEvent.lane); + const defaults = hwlabDefaultRuntimeTarget(); + const node = eventNode ?? defaults.node; + const lane = eventLane ?? defaults.lane; + const missing = [ + eventNode === null ? "node" : null, + eventLane === null ? "lane" : null, + ].filter((value): value is string => value !== null); + return { + command: `bun scripts/cli.ts hwlab nodes control-plane status --node ${node} --lane ${lane} --pipeline-run ${pipelineRun}`, + warning: missing.length === 0 + ? null + : `runtime lane progress event missing ${missing.join("/")} for nextCommand; used YAML default ${defaults.node}/${defaults.lane} from config/hwlab-node-lanes.yaml`, + }; +} + function sshTcpPoolDiagnosticsFromJobText(text: string): Record | null { const hint = lastJsonLinePayload(text, "UNIDESK_SSH_TCP_POOL_HINT"); if (hint !== null) return hint;