fix: add aipod imageRef work-ready runner reuse
This commit is contained in:
@@ -4,6 +4,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { AgentRunError } from "./errors.js";
|
||||
import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef, WorkspaceRef } from "./types.js";
|
||||
import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
|
||||
import { imageRefSourceSummary, validateAipodImageRef } from "./env-image-ref.js";
|
||||
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validateResourceBundleRef, validateSessionRef } from "./validation.js";
|
||||
|
||||
const aipodApiVersion = "agentrun.pikastech.local/v0.1";
|
||||
@@ -75,6 +76,7 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
|
||||
const labels = metadata.labels === undefined ? undefined : asRecord(metadata.labels, "aipodSpec.metadata.labels");
|
||||
const spec = asRecord(record.spec, "aipodSpec.spec");
|
||||
const backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile"));
|
||||
const imageRef = validateAipodImageRef(spec.imageRef, "aipodSpec.spec.imageRef");
|
||||
const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy"));
|
||||
validateAipodProviderCredential(backendProfile, executionPolicy);
|
||||
const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef);
|
||||
@@ -96,6 +98,7 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
|
||||
...(stringValue(spec.providerId) ? { providerId: stringValue(spec.providerId) as string } : {}),
|
||||
backendProfile,
|
||||
...(isJsonRecord(spec.model) ? { model: spec.model } : {}),
|
||||
imageRef,
|
||||
...(isJsonRecord(spec.workspaceRef) ? { workspaceRef: validateWorkspaceRef(spec.workspaceRef) } : {}),
|
||||
...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}),
|
||||
executionPolicy,
|
||||
@@ -111,7 +114,8 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
|
||||
|
||||
export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask {
|
||||
const spec = record.spec.spec;
|
||||
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash });
|
||||
const imageRef = imageRefSourceSummary(spec.imageRef);
|
||||
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash, aipodImageRef: imageRef });
|
||||
const payload = mergeRecords(spec.payloadDefaults, input.payload);
|
||||
if (typeof input.prompt === "string" && input.prompt.trim().length > 0) payload.prompt = input.prompt;
|
||||
applyModelPayload(payload, spec.model);
|
||||
@@ -138,7 +142,7 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
|
||||
action: "aipod-spec-render",
|
||||
aipod: summarizeAipodSpecRecord(record),
|
||||
queueTask,
|
||||
dispatchDefaults: spec.dispatchDefaults ?? {},
|
||||
dispatchDefaults: aipodDispatchDefaults(spec.dispatchDefaults, spec.imageRef),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -153,6 +157,7 @@ export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord {
|
||||
source: record.source,
|
||||
backendProfile: spec.backendProfile,
|
||||
model: spec.model ?? null,
|
||||
imageRef: imageRefSourceSummary(spec.imageRef),
|
||||
queue: spec.queue ?? "commander",
|
||||
lane: spec.lane ?? "v0.1",
|
||||
providerId: spec.providerId ?? "G14",
|
||||
@@ -254,6 +259,13 @@ function mergeRecords(...records: Array<JsonRecord | undefined>): JsonRecord {
|
||||
return Object.assign({}, ...records.filter(Boolean));
|
||||
}
|
||||
|
||||
function aipodDispatchDefaults(base: JsonRecord | undefined, imageRef: AipodSpec["spec"]["imageRef"]): JsonRecord {
|
||||
const result = mergeRecords(base);
|
||||
const runnerJob = mergeRecords(isJsonRecord(result.runnerJob) ? result.runnerJob : undefined, { imageRef });
|
||||
result.runnerJob = runnerJob;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyModelPayload(payload: JsonRecord, model: JsonRecord | undefined): void {
|
||||
if (!model) return;
|
||||
const modelName = stringValue(model.model);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { AgentRunError } from "./errors.js";
|
||||
import type { AipodImageRef, JsonRecord } from "./types.js";
|
||||
import { asRecord, stableHash } from "./validation.js";
|
||||
|
||||
export interface RunnerEnvImageResolution extends JsonRecord {
|
||||
status: "explicit-image" | "catalog-reused" | "runtime-default-reused" | "legacy-default";
|
||||
image: string;
|
||||
imageRef: JsonRecord | null;
|
||||
envIdentity: string | null;
|
||||
digestPinned: boolean;
|
||||
catalogFile: string | null;
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
export function validateAipodImageRef(value: unknown, fieldName = "imageRef"): AipodImageRef {
|
||||
const record = asRecord(value, fieldName);
|
||||
const kind = requiredString(record, "kind", fieldName);
|
||||
if (kind !== "env-image-dockerfile") throw new AgentRunError("schema-invalid", `${fieldName}.kind must be env-image-dockerfile`, { httpStatus: 400 });
|
||||
const repoUrl = validateRepoUrl(requiredString(record, "repoUrl", fieldName), `${fieldName}.repoUrl`);
|
||||
const commitId = requiredString(record, "commitId", fieldName).toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName}.commitId must be a full 40-character git commit sha`, { httpStatus: 400 });
|
||||
const dockerfilePath = validateDockerfilePath(requiredString(record, "dockerfilePath", fieldName), `${fieldName}.dockerfilePath`);
|
||||
return { kind: "env-image-dockerfile", repoUrl, commitId, dockerfilePath };
|
||||
}
|
||||
|
||||
export function imageRefSourceSummary(imageRef: AipodImageRef): JsonRecord {
|
||||
return {
|
||||
kind: imageRef.kind,
|
||||
repoUrl: imageRef.repoUrl,
|
||||
commitId: imageRef.commitId,
|
||||
dockerfilePath: imageRef.dockerfilePath,
|
||||
sourceIdentity: imageRefSourceIdentity(imageRef),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function imageRefSourceIdentity(imageRef: AipodImageRef): string {
|
||||
return stableHash({ kind: imageRef.kind, repoUrl: imageRef.repoUrl, commitId: imageRef.commitId, dockerfilePath: imageRef.dockerfilePath }).slice(0, 20);
|
||||
}
|
||||
|
||||
export function isDigestPinnedImage(image: string): boolean {
|
||||
return /@sha256:[0-9a-f]{64}$/u.test(image);
|
||||
}
|
||||
|
||||
export async function resolveRunnerEnvImage(options: { imageRef?: unknown; explicitImage?: string | null; defaultImage?: string | null; envIdentity?: string | null; artifactCatalogFile?: string | null }): Promise<RunnerEnvImageResolution> {
|
||||
const imageRef = options.imageRef === undefined || options.imageRef === null ? null : validateAipodImageRef(options.imageRef, "runnerJob.imageRef");
|
||||
const explicitImage = stringValue(options.explicitImage);
|
||||
const defaultImage = stringValue(options.defaultImage);
|
||||
const catalogFile = stringValue(options.artifactCatalogFile);
|
||||
const envIdentity = stringValue(options.envIdentity);
|
||||
|
||||
if (!imageRef) {
|
||||
const image = explicitImage ?? defaultImage;
|
||||
if (!image) throw new AgentRunError("schema-invalid", "runner job image is required; set --image or AGENTRUN_RUNNER_IMAGE", { httpStatus: 400 });
|
||||
return { status: explicitImage ? "explicit-image" : "legacy-default", image, imageRef: null, envIdentity: envIdentity ?? null, digestPinned: isDigestPinnedImage(image), catalogFile: catalogFile ?? null, valuesPrinted: false };
|
||||
}
|
||||
|
||||
if (explicitImage) {
|
||||
throw new AgentRunError("schema-invalid", "runnerJob.imageRef resolves the env image; do not pass runnerJob.image for Aipod-dispatched jobs", {
|
||||
httpStatus: 400,
|
||||
details: { imageRef: imageRefSourceSummary(imageRef), valuesPrinted: false },
|
||||
});
|
||||
}
|
||||
|
||||
const catalogResolution = catalogFile ? await resolveFromCatalog(catalogFile, imageRef) : null;
|
||||
if (catalogResolution) return catalogResolution;
|
||||
|
||||
if (defaultImage && isDigestPinnedImage(defaultImage)) {
|
||||
return {
|
||||
status: "runtime-default-reused",
|
||||
image: defaultImage,
|
||||
imageRef: imageRefSourceSummary(imageRef),
|
||||
envIdentity: envIdentity ?? imageRefSourceIdentity(imageRef),
|
||||
digestPinned: true,
|
||||
catalogFile: catalogFile ?? null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw new AgentRunError("schema-invalid", "Aipod runner env image is not reusable yet: imageRef requires a catalog hit or digest-pinned AGENTRUN_RUNNER_IMAGE", {
|
||||
httpStatus: 409,
|
||||
details: { imageRef: imageRefSourceSummary(imageRef), catalogFile: catalogFile ?? null, defaultImageDigestPinned: defaultImage ? isDigestPinnedImage(defaultImage) : false, buildRequired: true, valuesPrinted: false },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFromCatalog(catalogFile: string, imageRef: AipodImageRef): Promise<RunnerEnvImageResolution | null> {
|
||||
let parsed: JsonRecord;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(catalogFile, "utf8")) as JsonRecord;
|
||||
} catch (error) {
|
||||
throw new AgentRunError("infra-failed", `artifact catalog ${catalogFile} could not be read`, { httpStatus: 502, details: { catalogFile, message: error instanceof Error ? error.message : String(error), valuesPrinted: false } });
|
||||
}
|
||||
const services = Array.isArray(parsed.services) ? parsed.services : [];
|
||||
const requestedSummary = imageRefSourceSummary(imageRef);
|
||||
for (const item of services) {
|
||||
if (!isRecord(item)) continue;
|
||||
if (!catalogServiceMatchesImageRef(item, imageRef, requestedSummary)) continue;
|
||||
const image = stringValue(item.envRepositoryDigest) ?? stringValue(item.repositoryDigest) ?? stringValue(item.image);
|
||||
if (!image) continue;
|
||||
if (!isDigestPinnedImage(image)) {
|
||||
throw new AgentRunError("schema-invalid", "artifact catalog matched imageRef but did not provide a digest-pinned image", { httpStatus: 409, details: { catalogFile, imageRef: requestedSummary, image, valuesPrinted: false } });
|
||||
}
|
||||
return {
|
||||
status: "catalog-reused",
|
||||
image,
|
||||
imageRef: requestedSummary,
|
||||
envIdentity: stringValue(item.envIdentity) ?? imageRefSourceIdentity(imageRef),
|
||||
digestPinned: true,
|
||||
catalogFile,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function catalogServiceMatchesImageRef(service: JsonRecord, imageRef: AipodImageRef, requestedSummary: JsonRecord): boolean {
|
||||
const direct = normalizedImageRefOrNull(service.imageRef);
|
||||
if (direct && sameImageRef(direct, imageRef)) return true;
|
||||
const provenance = isRecord(service.provenance) ? service.provenance : null;
|
||||
const provenanceRef = normalizedImageRefOrNull(provenance?.imageRef);
|
||||
if (provenanceRef && sameImageRef(provenanceRef, imageRef)) return true;
|
||||
return stringValue(service.imageRefSourceIdentity) === requestedSummary.sourceIdentity || stringValue(service.envIdentity) === requestedSummary.sourceIdentity;
|
||||
}
|
||||
|
||||
function normalizedImageRefOrNull(value: unknown): AipodImageRef | null {
|
||||
if (!isRecord(value)) return null;
|
||||
try {
|
||||
return validateAipodImageRef(value, "catalog.imageRef");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sameImageRef(left: AipodImageRef, right: AipodImageRef): boolean {
|
||||
return left.kind === right.kind && left.repoUrl === right.repoUrl && left.commitId === right.commitId && left.dockerfilePath === right.dockerfilePath;
|
||||
}
|
||||
|
||||
function validateRepoUrl(value: string, fieldName: string): string {
|
||||
if (/\s|[\x00-\x1f\x7f]/u.test(value)) throw new AgentRunError("schema-invalid", `${fieldName} must not contain whitespace or control characters`, { httpStatus: 400 });
|
||||
if (value.includes("://")) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new AgentRunError("schema-invalid", `${fieldName} must be a valid git repo URL`, { httpStatus: 400 });
|
||||
}
|
||||
if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) throw new AgentRunError("schema-invalid", `${fieldName} must use http(s), ssh, or git protocol`, { httpStatus: 400 });
|
||||
if (url.password || (url.username && !(url.protocol === "ssh:" && url.username === "git"))) throw new AgentRunError("schema-invalid", `${fieldName} must not include credentials`, { httpStatus: 400 });
|
||||
if (url.search || url.hash) throw new AgentRunError("schema-invalid", `${fieldName} must not include query or fragment`, { httpStatus: 400 });
|
||||
return value;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[A-Za-z0-9._/-]+(?:\.git)?$/u.test(value)) throw new AgentRunError("schema-invalid", `${fieldName} must be a git repo URL`, { httpStatus: 400 });
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateDockerfilePath(value: string, fieldName: string): string {
|
||||
if (value === "." || value.startsWith("/") || value.endsWith("/") || value.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must be a repository-relative file path`, { httpStatus: 400 });
|
||||
const parts = value.split("/");
|
||||
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredString(record: JsonRecord, key: string, fieldName: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${fieldName}.${key} is required`, { httpStatus: 400 });
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -61,6 +61,13 @@ export interface SecretRef extends JsonRecord {
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export interface AipodImageRef extends JsonRecord {
|
||||
kind: "env-image-dockerfile";
|
||||
repoUrl: string;
|
||||
commitId: string;
|
||||
dockerfilePath: string;
|
||||
}
|
||||
|
||||
export interface GitBundleItemRef extends JsonRecord {
|
||||
name?: string;
|
||||
repoUrl?: string;
|
||||
@@ -112,6 +119,7 @@ export interface AipodSpec extends JsonRecord {
|
||||
providerId?: string;
|
||||
backendProfile: BackendProfile;
|
||||
model?: JsonRecord;
|
||||
imageRef: AipodImageRef;
|
||||
workspaceRef?: WorkspaceRef;
|
||||
sessionRef?: SessionRef | null;
|
||||
executionPolicy: ExecutionPolicy;
|
||||
@@ -456,6 +464,8 @@ export interface QueueDispatchResult extends JsonRecord {
|
||||
run: RunRecord;
|
||||
command: CommandRecord;
|
||||
runnerJob: JsonRecord;
|
||||
envImage: JsonRecord | null;
|
||||
workReady: JsonRecord | null;
|
||||
latestAttempt: QueueAttemptRef;
|
||||
pollCommands: JsonRecord;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import { AgentRunError } from "./errors.js";
|
||||
import { stableHash } from "./validation.js";
|
||||
import type { JsonRecord } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const toolTimeoutMs = 5_000;
|
||||
|
||||
export const workReadyVersion = "v0.1-runner-work-ready-20260610";
|
||||
|
||||
export const imageWorkReadyTools = Object.freeze([
|
||||
{ name: "bun", command: "bun", args: ["--version"] },
|
||||
{ name: "node", command: "node", args: ["--version"] },
|
||||
{ name: "npm", command: "npm", args: ["--version"] },
|
||||
{ name: "git", command: "git", args: ["--version"] },
|
||||
{ name: "ssh", command: "ssh", args: ["-V"], versionFrom: "stderr" as const },
|
||||
{ name: "gh", command: "gh", args: ["--version"], firstLine: true },
|
||||
{ name: "rg", command: "rg", args: ["--version"], firstLine: true },
|
||||
{ name: "curl", command: "curl", args: ["--version"], firstLine: true },
|
||||
{ name: "kubectl", command: "kubectl", args: ["version", "--client"], firstLine: true },
|
||||
]);
|
||||
|
||||
export const bundledWorkReadyTools = Object.freeze([
|
||||
{ name: "tran", path: "/usr/local/bin/tran" },
|
||||
{ name: "trans", path: "/usr/local/bin/trans" },
|
||||
{ name: "apply_patch", path: "/usr/local/bin/apply_patch" },
|
||||
]);
|
||||
|
||||
export function staticWorkReadyCapabilitySummary(): JsonRecord {
|
||||
return {
|
||||
version: workReadyVersion,
|
||||
requiredImageTools: imageWorkReadyTools.map((tool) => tool.name),
|
||||
requiredBundledTools: bundledWorkReadyTools.map((tool) => tool.name),
|
||||
packageLayer: {
|
||||
osFamily: "alpine",
|
||||
packageManager: "apk",
|
||||
runtimeInstallPolicy: "forbidden-for-common-tasks",
|
||||
notes: ["基础 CLI 和 AgentRun npm 依赖必须在镜像构建阶段准备;普通任务不得运行 apt/apk/bun/npm install 来补基础环境。"],
|
||||
},
|
||||
dependencyStrategy: {
|
||||
agentrunNodeModules: "image-layer:/opt/agentrun/node_modules",
|
||||
runnerBootNodeModules: "symlink:/workspace/agentrun/node_modules -> /opt/agentrun/node_modules",
|
||||
projectDependencies: "not-installed-by-default",
|
||||
projectDependencyCache: "explicit-task-or-derived-image-only",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function smokeImageWorkReadyCapabilities(env: NodeJS.ProcessEnv = process.env): Promise<JsonRecord> {
|
||||
const toolResults = await Promise.all(imageWorkReadyTools.map((tool) => checkCommand(tool, env)));
|
||||
const missing = toolResults.filter((item) => item.ok !== true).map((item) => item.name);
|
||||
const summary = {
|
||||
...staticWorkReadyCapabilitySummary(),
|
||||
imageTools: toolResults,
|
||||
smoke: { ok: missing.length === 0, scope: "image", missing, checkedAt: new Date().toISOString(), valuesPrinted: false },
|
||||
capabilityHash: stableHash({ version: workReadyVersion, toolResults }),
|
||||
valuesPrinted: false,
|
||||
} satisfies JsonRecord;
|
||||
if (missing.length > 0) {
|
||||
throw new AgentRunError("infra-failed", `runner image is not work-ready; missing required image tools: ${missing.join(", ")}`, { httpStatus: 503, details: summary });
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function smokeBundledWorkReadyCapabilities(env: NodeJS.ProcessEnv = process.env): Promise<JsonRecord> {
|
||||
const toolResults = await Promise.all(bundledWorkReadyTools.map((tool) => checkExecutable(tool, env)));
|
||||
const missing = toolResults.filter((item) => item.ok !== true).map((item) => item.name);
|
||||
const summary = {
|
||||
...staticWorkReadyCapabilitySummary(),
|
||||
bundledTools: toolResults,
|
||||
smoke: { ok: missing.length === 0, scope: "bundle", missing, checkedAt: new Date().toISOString(), valuesPrinted: false },
|
||||
capabilityHash: stableHash({ version: workReadyVersion, toolResults }),
|
||||
valuesPrinted: false,
|
||||
} satisfies JsonRecord;
|
||||
if (missing.length > 0) {
|
||||
throw new AgentRunError("infra-failed", `runner bundle is not work-ready; missing required bundled tools: ${missing.join(", ")}`, { httpStatus: 503, details: summary });
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function checkCommand(tool: { name: string; command: string; args: string[]; versionFrom?: "stdout" | "stderr"; firstLine?: boolean }, env: NodeJS.ProcessEnv): Promise<JsonRecord> {
|
||||
try {
|
||||
const result = await execFileAsync(tool.command, tool.args, { timeout: toolTimeoutMs, env: redactedToolEnv(env) });
|
||||
const versionText = tool.versionFrom === "stderr" ? result.stderr : result.stdout || result.stderr;
|
||||
return { name: tool.name, command: tool.command, ok: true, version: normalizeVersion(versionText, tool.firstLine), valuesPrinted: false };
|
||||
} catch (error) {
|
||||
return { name: tool.name, command: tool.command, ok: false, failureKind: "tool-unavailable", message: error instanceof Error ? error.message : String(error), valuesPrinted: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkExecutable(tool: { name: string; path: string }, env: NodeJS.ProcessEnv): Promise<JsonRecord> {
|
||||
const candidate = pathForBundledTool(tool, env);
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return { name: tool.name, path: candidate, ok: true, valuesPrinted: false };
|
||||
} catch (error) {
|
||||
return { name: tool.name, path: candidate, ok: false, failureKind: "tool-unavailable", message: error instanceof Error ? error.message : String(error), valuesPrinted: false };
|
||||
}
|
||||
}
|
||||
|
||||
function pathForBundledTool(tool: { name: string; path: string }, env: NodeJS.ProcessEnv): string {
|
||||
const binPath = env.AGENTRUN_RESOURCE_BIN_PATH;
|
||||
if (binPath && binPath.trim().length > 0) return `${binPath.replace(/\/+$/u, "")}/${tool.name}`;
|
||||
return tool.path;
|
||||
}
|
||||
|
||||
function normalizeVersion(value: string, firstLine: boolean | undefined): string {
|
||||
const normalized = firstLine ? value.trim().split(/\r?\n/u)[0] ?? "" : value.trim().replace(/\s+/gu, " ");
|
||||
return normalized.slice(0, 160);
|
||||
}
|
||||
|
||||
function redactedToolEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const allowedKeys = ["PATH", "HOME", "KUBECONFIG", "SSL_CERT_FILE", "SSL_CERT_DIR"];
|
||||
const next: NodeJS.ProcessEnv = {};
|
||||
for (const key of allowedKeys) {
|
||||
const value = env[key] ?? process.env[key];
|
||||
if (value !== undefined) next[key] = value;
|
||||
}
|
||||
const selftestBin = env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH ?? process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
||||
if (selftestBin && selftestBin.trim().length > 0) next.PATH = `${selftestBin}${pathDelimiter()}${next.PATH ?? ""}`;
|
||||
return next;
|
||||
}
|
||||
|
||||
function pathDelimiter(): string {
|
||||
return process.platform === "win32" ? ";" : ":";
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type { ExecutionPolicy, JsonRecord } from "../common/types.js";
|
||||
import { stableHash, validateEnvName } from "../common/validation.js";
|
||||
import { renderRunnerJobManifest } from "../runner/k8s-job.js";
|
||||
import type { RunnerSessionPvcOptions, RunnerTransientEnv } from "../runner/k8s-job.js";
|
||||
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
||||
import { resolveRunnerEnvImage } from "../common/env-image-ref.js";
|
||||
|
||||
const reusableCredentialEnvNames = new Set([
|
||||
"AUTH_PASSWORD",
|
||||
@@ -36,6 +38,8 @@ export interface RunnerJobDefaults {
|
||||
managerUrl: string;
|
||||
image: string;
|
||||
sourceCommit: string;
|
||||
envIdentity?: string;
|
||||
artifactCatalogFile?: string;
|
||||
serviceAccountName?: string;
|
||||
kubectlCommand?: string;
|
||||
unideskSshEndpointEnv?: JsonRecord;
|
||||
@@ -51,6 +55,7 @@ export interface CreateRunnerJobInput extends JsonRecord {
|
||||
sourceCommit?: string;
|
||||
serviceAccountName?: string;
|
||||
idempotencyKey?: string;
|
||||
imageRef?: JsonRecord;
|
||||
transientEnv?: JsonRecord[];
|
||||
}
|
||||
|
||||
@@ -61,8 +66,14 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
if (command.runId !== run.id) throw new AgentRunError("schema-invalid", `command ${commandId} does not belong to run ${run.id}`, { httpStatus: 400 });
|
||||
if (command.type !== "turn") throw new AgentRunError("schema-invalid", `command ${commandId} is not a turn command`, { httpStatus: 400 });
|
||||
|
||||
const image = optionalString(options.input.image) ?? options.defaults.image;
|
||||
if (!image) throw new AgentRunError("schema-invalid", "runner job image is required; set --image or AGENTRUN_RUNNER_IMAGE", { httpStatus: 400 });
|
||||
const envImage = await resolveRunnerEnvImage({
|
||||
...(options.input.imageRef !== undefined ? { imageRef: options.input.imageRef } : {}),
|
||||
...(optionalString(options.input.image) ? { explicitImage: optionalString(options.input.image) as string } : {}),
|
||||
defaultImage: options.defaults.image,
|
||||
...(options.defaults.envIdentity ? { envIdentity: options.defaults.envIdentity } : {}),
|
||||
...(options.defaults.artifactCatalogFile ? { artifactCatalogFile: options.defaults.artifactCatalogFile } : {}),
|
||||
});
|
||||
const image = envImage.image;
|
||||
const namespace = optionalString(options.input.namespace) ?? options.defaults.namespace;
|
||||
const managerUrl = optionalString(options.input.managerUrl) ?? options.defaults.managerUrl;
|
||||
const sourceCommit = optionalString(options.input.sourceCommit) ?? options.defaults.sourceCommit;
|
||||
@@ -79,6 +90,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
namespace,
|
||||
managerUrl,
|
||||
sourceCommit,
|
||||
envImage,
|
||||
serviceAccountName: serviceAccountName ?? null,
|
||||
attemptId: optionalString(options.input.attemptId) ?? null,
|
||||
runnerId: optionalString(options.input.runnerId) ?? null,
|
||||
@@ -144,6 +156,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
runnerId: render.runnerId,
|
||||
namespace: render.namespace,
|
||||
jobName: render.jobName,
|
||||
image,
|
||||
jobIdentity: {
|
||||
kind: "Job",
|
||||
namespace: render.namespace,
|
||||
@@ -158,14 +171,17 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
runnerId: render.runnerId,
|
||||
backendProfile: run.backendProfile,
|
||||
managerUrl,
|
||||
image,
|
||||
sourceCommit,
|
||||
placement: "kubernetes-job",
|
||||
logPath: `kubectl -n ${render.namespace} logs job/${render.jobName}`,
|
||||
},
|
||||
envImage,
|
||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
transientEnv: summarizeTransientEnv(transientEnv),
|
||||
transientEnvSecret: transientEnvSecretResponse,
|
||||
workReady: staticWorkReadyCapabilitySummary(),
|
||||
retention: {
|
||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
||||
},
|
||||
@@ -208,6 +224,8 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
||||
idempotencyKey: idempotencyKey ? "present" : null,
|
||||
transientEnv: summarizeTransientEnv(transientEnv),
|
||||
transientEnvSecret: transientEnvSecretResponse,
|
||||
envImage,
|
||||
workReady: staticWorkReadyCapabilitySummary(),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
|
||||
resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null),
|
||||
|
||||
@@ -49,6 +49,8 @@ export async function dispatchQueueTask(options: DispatchQueueTaskOptions): Prom
|
||||
run,
|
||||
command,
|
||||
runnerJob,
|
||||
envImage: jsonRecordOrNull(runnerJob.envImage),
|
||||
workReady: jsonRecordOrNull(runnerJob.workReady),
|
||||
latestAttempt,
|
||||
pollCommands: {
|
||||
queue: `./scripts/agentrun queue show ${task.id}`,
|
||||
@@ -127,6 +129,8 @@ function buildRunnerJobInput(task: QueueTaskRecord, commandId: string, input: Js
|
||||
copyString("runnerId", "runnerId");
|
||||
copyString("sourceCommit", "sourceCommit");
|
||||
copyString("serviceAccountName", "serviceAccountName");
|
||||
const imageRef = jsonRecordOrNull(input.imageRef) ?? jsonRecordOrNull(task.metadata.aipodImageRef);
|
||||
if (imageRef) jobInput.imageRef = imageRef;
|
||||
if (input.transientEnv !== undefined) {
|
||||
if (!Array.isArray(input.transientEnv)) throw new AgentRunError("schema-invalid", "transientEnv must be an array", { httpStatus: 400 });
|
||||
jobInput.transientEnv = input.transientEnv.map((item, index) => asRecord(item, `transientEnv[${index}]`));
|
||||
@@ -140,6 +144,11 @@ function stringFrom(record: JsonRecord, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonRecordOrNull(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export function runnerJobStatusSummary(job: RunnerJobRecord, events: RunEvent[]
|
||||
const jobIdentity = recordAt(job.result, "jobIdentity");
|
||||
const kubernetes = recordAt(job.result, "kubernetes");
|
||||
const retention = recordAt(job.result, "retention");
|
||||
const envImage = recordAt(job.result, "envImage");
|
||||
const terminalStatus = terminalEvent?.payload.terminalStatus;
|
||||
return {
|
||||
id: job.id,
|
||||
@@ -17,6 +18,7 @@ export function runnerJobStatusSummary(job: RunnerJobRecord, events: RunEvent[]
|
||||
jobName: job.jobName,
|
||||
managerUrl: job.managerUrl,
|
||||
image: job.image,
|
||||
envImage,
|
||||
sourceCommit: job.sourceCommit,
|
||||
serviceAccountName: job.serviceAccountName,
|
||||
phase: terminalStatus ? `terminal:${terminalStatus}` : kubernetes.created === true ? "created" : "recorded",
|
||||
|
||||
+12
-1
@@ -17,6 +17,7 @@ import type { SessionPvcOptions } from "./session-pvc.js";
|
||||
import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||
import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js";
|
||||
import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js";
|
||||
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
||||
|
||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||
@@ -42,6 +43,8 @@ export interface ManagerServerOptions {
|
||||
namespace?: string;
|
||||
managerUrl?: string;
|
||||
image?: string;
|
||||
envIdentity?: string;
|
||||
artifactCatalogFile?: string;
|
||||
serviceAccountName?: string;
|
||||
kubectlCommand?: string;
|
||||
unideskSshEndpointEnv?: JsonRecord;
|
||||
@@ -211,7 +214,7 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
const database = await store.health();
|
||||
const ready = path === "/health/live" ? true : database.ready;
|
||||
return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } };
|
||||
return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, runnerWorkReady: staticWorkReadyCapabilitySummary(), secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } };
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) as JsonValue;
|
||||
@@ -411,6 +414,8 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
managerUrl: runnerJobDefaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`,
|
||||
image: runnerJobDefaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "",
|
||||
sourceCommit,
|
||||
...optionalStringRecord("envIdentity", runnerJobDefaults?.envIdentity ?? process.env.AGENTRUN_ENV_IDENTITY),
|
||||
...optionalStringRecord("artifactCatalogFile", runnerJobDefaults?.artifactCatalogFile ?? process.env.AGENTRUN_ARTIFACT_CATALOG_FILE),
|
||||
serviceAccountName: runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner",
|
||||
...(runnerJobDefaults?.kubectlCommand ? { kubectlCommand: runnerJobDefaults.kubectlCommand } : {}),
|
||||
...(runnerJobDefaults?.unideskSshEndpointEnv ? { unideskSshEndpointEnv: runnerJobDefaults.unideskSshEndpointEnv } : {}),
|
||||
@@ -473,6 +478,8 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
managerUrl: runnerJobDefaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`,
|
||||
image: runnerJobDefaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "",
|
||||
sourceCommit,
|
||||
...optionalStringRecord("envIdentity", runnerJobDefaults?.envIdentity ?? process.env.AGENTRUN_ENV_IDENTITY),
|
||||
...optionalStringRecord("artifactCatalogFile", runnerJobDefaults?.artifactCatalogFile ?? process.env.AGENTRUN_ARTIFACT_CATALOG_FILE),
|
||||
serviceAccountName: runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner",
|
||||
...(runnerJobDefaults?.kubectlCommand ? { kubectlCommand: runnerJobDefaults.kubectlCommand } : {}),
|
||||
...(runnerJobDefaults?.unideskSshEndpointEnv ? { unideskSshEndpointEnv: runnerJobDefaults.unideskSshEndpointEnv } : {}),
|
||||
@@ -597,6 +604,10 @@ function stringField(record: JsonRecord, key: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalStringRecord(key: string, value: unknown): JsonRecord {
|
||||
return typeof value === "string" && value.trim().length > 0 ? { [key]: value.trim() } : {};
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): AgentRunError {
|
||||
if (error instanceof AgentRunError) return error;
|
||||
return new AgentRunError("infra-failed", error instanceof Error ? error.message : String(error), { httpStatus: 500 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { stableHash } from "../common/validation.js";
|
||||
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js";
|
||||
import { backendProfileSpec } from "../common/backend-profiles.js";
|
||||
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
||||
|
||||
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
|
||||
const defaultResourceBinPath = "/usr/local/bin";
|
||||
@@ -126,6 +127,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
|
||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
|
||||
workReady: staticWorkReadyCapabilitySummary(),
|
||||
retention: {
|
||||
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
|
||||
},
|
||||
@@ -241,6 +243,8 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
|
||||
{ name: "AGENTRUN_RUNTIME_NAMESPACE", value: context.namespace },
|
||||
{ name: "AGENTRUN_K8S_JOB_NAME", value: context.jobName },
|
||||
{ name: "AGENTRUN_LOG_PATH", value: "/tmp/agentrun-runner.jsonl" },
|
||||
{ name: "AGENTRUN_WORK_READY_VERSION", value: String(staticWorkReadyCapabilitySummary().version) },
|
||||
{ name: "AGENTRUN_PROJECT_DEPENDENCY_POLICY", value: "explicit-cache-or-derived-image-only" },
|
||||
{ name: "AGENTRUN_RUNNER_IDLE_TIMEOUT_MS", value: "600000" },
|
||||
{ name: "AGENTRUN_RUNNER_POLL_INTERVAL_MS", value: "250" },
|
||||
{ name: "HOME", value: "/home/agentrun" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createBackendSession, runBackendTurn, type BackendActiveTurnControl, ty
|
||||
import { materializeResourceBundle } from "./resource-bundle.js";
|
||||
import type { BackendEvent, BackendProfile, CommandRecord, FailureKind, InitialPromptAssembly, JsonRecord, RunRecord, RunnerRecord, TerminalStatus } from "../common/types.js";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import { smokeBundledWorkReadyCapabilities, smokeImageWorkReadyCapabilities } from "../common/work-ready.js";
|
||||
|
||||
export interface RunnerOnceOptions extends BackendAdapterOptions {
|
||||
managerUrl: string;
|
||||
@@ -57,6 +58,17 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
...(options.podName ? { podName: options.podName } : {}),
|
||||
...(options.logPath ? { logPath: options.logPath } : {}),
|
||||
}) as RunnerRecord;
|
||||
try {
|
||||
const imageWorkReady = await smokeImageWorkReadyCapabilities(options.env ?? process.env);
|
||||
await api.appendEvent(options.runId, { type: "backend_status", payload: { phase: "runner-image-work-ready-smoke", attemptId, runnerId: runner.id, ...imageWorkReady } });
|
||||
} catch (error) {
|
||||
const failureKind = failureKindFromError(error);
|
||||
const message = errorMessage(error);
|
||||
const details = failureDetailsFromError(error);
|
||||
await api.appendEvent(options.runId, { type: "error", payload: { failureKind, message, phase: "runner-image-work-ready-smoke", attemptId, runnerId: runner.id, ...(details ? { details } : {}) } });
|
||||
const finalRun = await api.reportStatus(options.runId, { terminalStatus: terminalStatusForFailure(failureKind), failureKind, failureMessage: message }) as RunRecord;
|
||||
return { runner, terminalStatus: finalRun.terminalStatus, failureKind, run: finalRun, commandsProcessed: 0, commandResults: [], stopped: "image-work-ready-smoke-failed" } as JsonRecord;
|
||||
}
|
||||
let claimed: RunRecord;
|
||||
try {
|
||||
claimed = await claimRunWithLeaseRecovery(api, options, runner, attemptId, leaseMs);
|
||||
@@ -108,6 +120,10 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
|
||||
initialPrompt = materialized.initialPrompt;
|
||||
await api.appendEvent(options.runId, { type: "backend_status", payload: { ...materialized.event, commandId: command.id, attemptId, runnerId: runner.id } });
|
||||
if (requiresBundledWorkReadyTools(claimed)) {
|
||||
const bundleWorkReady = await smokeBundledWorkReadyCapabilities(resourceEnv ?? options.env ?? process.env);
|
||||
await api.appendEvent(options.runId, { type: "backend_status", payload: { phase: "runner-bundle-work-ready-smoke", commandId: command.id, attemptId, runnerId: runner.id, ...bundleWorkReady } });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const failureKind = failureKindFromError(error);
|
||||
@@ -144,6 +160,13 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
function requiresBundledWorkReadyTools(run: RunRecord): boolean {
|
||||
const toolCredentials = run.executionPolicy.secretScope.toolCredentials ?? [];
|
||||
if (toolCredentials.some((item) => item.tool === "unidesk-ssh" || item.tool === "github")) return true;
|
||||
const requiredSkills = run.resourceBundleRef?.requiredSkills ?? [];
|
||||
return requiredSkills.some((item) => item.name === "unidesk-trans" || item.name === "unidesk-gh" || item.name === "unidesk-code-queue" || item.name === "unidesk-cicd" || item.name === "dad-dev");
|
||||
}
|
||||
|
||||
function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined, initialPrompt: InitialPromptAssembly | undefined): RunnerOnceOptions {
|
||||
return {
|
||||
...options,
|
||||
|
||||
@@ -10,11 +10,13 @@ const selfTest: SelfTestCase = async () => {
|
||||
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store });
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const health = await client.get("/health/readiness") as { database?: { adapter?: string; reachable?: boolean; migrationReady?: boolean; failureKind?: string | null }; secretRefs?: { valuesPrinted?: boolean } };
|
||||
const health = await client.get("/health/readiness") as { database?: { adapter?: string; reachable?: boolean; migrationReady?: boolean; failureKind?: string | null }; runnerWorkReady?: JsonRecord; secretRefs?: { valuesPrinted?: boolean } };
|
||||
assert.equal(health.database?.adapter, "memory-self-test");
|
||||
assert.equal(health.database?.reachable, true);
|
||||
assert.equal(health.database?.migrationReady, true);
|
||||
assert.equal(health.database?.failureKind, null);
|
||||
assert.equal(((health.runnerWorkReady as { valuesPrinted?: unknown } | undefined)?.valuesPrinted), false);
|
||||
assert.ok((((health.runnerWorkReady as { requiredImageTools?: string[] } | undefined)?.requiredImageTools) ?? []).includes("npm"));
|
||||
assert.equal(health.secretRefs?.valuesPrinted, false);
|
||||
await assertLongResultUsesTerminalAssistant(client, store);
|
||||
return { name: "manager-memory", tests: ["manager-memory-lifecycle", "manager-result-long-trace"] };
|
||||
|
||||
@@ -45,6 +45,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(rendered.mutation, false);
|
||||
assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 86_400);
|
||||
assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner");
|
||||
assertWorkReadySummary(rendered.workReady as JsonRecord);
|
||||
assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0");
|
||||
assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||
@@ -173,6 +174,7 @@ process.exit(1);
|
||||
namespace: "agentrun-v01",
|
||||
managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080",
|
||||
image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
envIdentity: "selftest-env-identity",
|
||||
kubectlCommand: fakeKubectl,
|
||||
unideskSshEndpointEnv: { name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.default.example.test" },
|
||||
},
|
||||
@@ -183,6 +185,12 @@ process.exit(1);
|
||||
const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, {
|
||||
commandId: jobItem.commandId,
|
||||
attemptId: "attempt_selftest_create",
|
||||
imageRef: {
|
||||
kind: "env-image-dockerfile",
|
||||
repoUrl: "git@github.com:pikasTech/agentrun.git",
|
||||
commitId: "59272f8edb4e23d805b7b9da0050ec40cfc0233d",
|
||||
dockerfilePath: "deploy/container/Containerfile",
|
||||
},
|
||||
transientEnv: [
|
||||
{ name: "HWLAB_API_KEY", value: "hwl_live_selftest", sensitive: true },
|
||||
{ name: "HWLAB_RUNTIME_API_URL", value: "http://runtime-api.test", sensitive: true },
|
||||
@@ -196,6 +204,10 @@ process.exit(1);
|
||||
],
|
||||
});
|
||||
assert.equal((created as { mutation?: unknown }).mutation, true);
|
||||
assert.equal((((created as JsonRecord).envImage as JsonRecord).status), "runtime-default-reused");
|
||||
assert.equal((((created as JsonRecord).envImage as JsonRecord).digestPinned), true);
|
||||
assert.equal(((((created as JsonRecord).envImage as JsonRecord).imageRef as JsonRecord).kind), "env-image-dockerfile");
|
||||
assertWorkReadySummary((created as JsonRecord).workReady as JsonRecord);
|
||||
assert.equal(((created as JsonRecord).retention as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||
assert.deepEqual((((created as JsonRecord).transientEnv as JsonRecord).names) as string[], ["HWLAB_API_KEY", "HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_SOURCE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", "UNIDESK_MAIN_SERVER_IP"]);
|
||||
const transientEnvSecret = (created as JsonRecord).transientEnvSecret as JsonRecord;
|
||||
@@ -319,6 +331,16 @@ function assertRunnerJobUsesTransientEnvSecret(manifest: JsonRecord, envName: st
|
||||
assert.equal(secretKeyRef.key, envName);
|
||||
}
|
||||
|
||||
function assertWorkReadySummary(summary: JsonRecord): void {
|
||||
assert.equal(summary.valuesPrinted, false);
|
||||
assert.equal(typeof summary.version, "string");
|
||||
assert.ok((summary.requiredImageTools as string[]).includes("bun"));
|
||||
assert.ok((summary.requiredImageTools as string[]).includes("npm"));
|
||||
assert.ok((summary.requiredImageTools as string[]).includes("gh"));
|
||||
assert.ok((summary.requiredBundledTools as string[]).includes("tran"));
|
||||
assert.equal(((summary.dependencyStrategy as JsonRecord).projectDependencies), "not-installed-by-default");
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesG14EgressProxy(manifest: JsonRecord): void {
|
||||
const proxy = "http://g14-provider-egress-proxy.unidesk.svc.cluster.local:18789";
|
||||
assert.equal(runnerEnvValue(manifest, "HTTP_PROXY"), proxy);
|
||||
|
||||
@@ -39,6 +39,12 @@ process.exit(1);
|
||||
`);
|
||||
await chmod(fakeKubectl, 0o755);
|
||||
const store = new MemoryAgentRunStore();
|
||||
const aipodImageRef = {
|
||||
kind: "env-image-dockerfile",
|
||||
repoUrl: "git@github.com:pikasTech/agentrun.git",
|
||||
commitId: "59272f8edb4e23d805b7b9da0050ec40cfc0233d",
|
||||
dockerfilePath: "deploy/container/Containerfile",
|
||||
};
|
||||
const server = await startManagerServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
@@ -48,6 +54,7 @@ process.exit(1);
|
||||
namespace: "agentrun-v01",
|
||||
managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080",
|
||||
image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
envIdentity: "selftest-env-identity",
|
||||
kubectlCommand: fakeKubectl,
|
||||
unideskSshEndpointEnv: { name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.default.example.test" },
|
||||
},
|
||||
@@ -94,7 +101,7 @@ process.exit(1);
|
||||
resourceBundleRef: null,
|
||||
payload: { prompt: "queue dispatch hello" },
|
||||
references: [{ kind: "issue", url: "https://github.com/pikasTech/agentrun/issues/39" }],
|
||||
metadata: { source: "queue-q2-self-test" },
|
||||
metadata: { source: "queue-q2-self-test", aipodImageRef },
|
||||
idempotencyKey: "queue-q2-dispatch-self-test",
|
||||
}) as QueueTaskRecord;
|
||||
const dispatchPlan = await runCliJson(context, server.baseUrl, ["queue", "dispatch", String(created.id), "--dry-run", "--attempt-id", "attempt_queue_q2_cli_dryrun"]);
|
||||
@@ -111,6 +118,12 @@ process.exit(1);
|
||||
const dispatched = await client.post(`/api/v1/queue/tasks/${created.id}/dispatch`, { attemptId: "attempt_queue_q2_selftest" }) as QueueDispatchResult;
|
||||
assert.equal(dispatched.action, "queue-dispatch");
|
||||
assert.equal(dispatched.mutation, true);
|
||||
assert.equal(((dispatched.envImage as JsonRecord).status), "runtime-default-reused");
|
||||
assert.equal(((dispatched.envImage as JsonRecord).digestPinned), true);
|
||||
assert.equal(((dispatched.runnerJob as JsonRecord).image), "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111");
|
||||
assert.equal(((((dispatched.runnerJob as JsonRecord).envImage as JsonRecord).imageRef as JsonRecord).dockerfilePath), "deploy/container/Containerfile");
|
||||
assert.equal(((dispatched.workReady as JsonRecord).valuesPrinted), false);
|
||||
assert.ok((((dispatched.workReady as JsonRecord).requiredImageTools as string[]) ?? []).includes("npm"));
|
||||
assert.equal(dispatched.latestAttempt.attemptId, "attempt_queue_q2_selftest");
|
||||
assert.equal(dispatched.latestAttempt.runId, dispatched.run.id);
|
||||
assert.equal(dispatched.latestAttempt.commandId, dispatched.command.id);
|
||||
|
||||
@@ -24,6 +24,11 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
const shownItem = shown.item as JsonRecord;
|
||||
assert.equal(shownItem.backendProfile, "sub2api");
|
||||
assert.equal(((shownItem.model as JsonRecord).model), "gpt-5.5");
|
||||
const shownImageRef = shownItem.imageRef as JsonRecord;
|
||||
assert.equal(shownImageRef.kind, "env-image-dockerfile");
|
||||
assert.equal(shownImageRef.repoUrl, "git@github.com:pikasTech/agentrun.git");
|
||||
assert.equal(shownImageRef.commitId, "59272f8edb4e23d805b7b9da0050ec40cfc0233d");
|
||||
assert.equal(shownImageRef.dockerfilePath, "deploy/container/Containerfile");
|
||||
assert.equal(((shownItem.resourceBundleRef as JsonRecord).gitMirror as JsonRecord).enabled, false);
|
||||
|
||||
const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "处理 pikasTech/unidesk#245", idempotencyKey: "selftest-aipod-artificer" }) as JsonRecord;
|
||||
@@ -32,6 +37,12 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(task.backendProfile, "sub2api");
|
||||
assert.equal(task.providerId, "G14");
|
||||
assert.equal(task.idempotencyKey, "selftest-aipod-artificer");
|
||||
const taskImageRef = ((task.metadata as JsonRecord).aipodImageRef as JsonRecord);
|
||||
assert.equal(taskImageRef.kind, "env-image-dockerfile");
|
||||
assert.equal(taskImageRef.valuesPrinted, false);
|
||||
const runnerDefaults = ((rendered.dispatchDefaults as JsonRecord).runnerJob as JsonRecord);
|
||||
assert.equal(((runnerDefaults.imageRef as JsonRecord).kind), "env-image-dockerfile");
|
||||
assert.equal(((runnerDefaults.imageRef as JsonRecord).dockerfilePath), "deploy/container/Containerfile");
|
||||
assert.equal(((task.payload as JsonRecord).model), "gpt-5.5");
|
||||
assert.equal((((task.payload as JsonRecord).modelConfig as JsonRecord).reasoningEffort), "xhigh");
|
||||
const policy = task.executionPolicy as JsonRecord;
|
||||
@@ -76,7 +87,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
|
||||
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>")), true);
|
||||
assertNoSecretLeak(submitPlan);
|
||||
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-direct-ssh-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
|
||||
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-direct-ssh-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import type { SelfTestCase } from "../harness.js";
|
||||
import { imageRefSourceIdentity, imageRefSourceSummary, isDigestPinnedImage, validateAipodImageRef } from "../../common/env-image-ref.js";
|
||||
import { smokeBundledWorkReadyCapabilities, smokeImageWorkReadyCapabilities, staticWorkReadyCapabilitySummary } from "../../common/work-ready.js";
|
||||
|
||||
const requiredRunnerPackages = Object.freeze(["git", "openssh-client", "ripgrep"]);
|
||||
const requiredRunnerPackages = Object.freeze(["ca-certificates", "curl", "git", "github-cli", "kubectl", "nodejs", "npm", "openssh-client", "ripgrep"]);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
@@ -18,6 +20,9 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
for (const packageName of requiredRunnerPackages) {
|
||||
assert.equal(apkPackages.has(packageName), true, `runner image must install ${packageName}`);
|
||||
}
|
||||
assert.equal(containerfile.includes("bun install --production"), true, "runner image must install AgentRun npm dependencies at image build time");
|
||||
assert.equal(containerfile.includes("npm --version"), true, "runner image build smoke must verify npm");
|
||||
assert.equal(containerfile.includes("gh --version"), true, "runner image build smoke must verify GitHub CLI");
|
||||
|
||||
assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
||||
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
||||
@@ -35,10 +40,39 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal(parsed.unsupported?.includes("apply-patch"), false);
|
||||
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
||||
const summary = staticWorkReadyCapabilitySummary();
|
||||
assert.equal(summary.valuesPrinted, false);
|
||||
assert.ok((summary.requiredImageTools as string[]).includes("npm"), "work-ready capability must include npm");
|
||||
assert.ok((summary.requiredImageTools as string[]).includes("gh"), "work-ready capability must include gh");
|
||||
assert.equal(((summary.dependencyStrategy as { projectDependencies?: unknown }).projectDependencies), "not-installed-by-default");
|
||||
const fakeBin = await createFakeToolBin(path.join(context.tmp, "work-ready-bin"));
|
||||
const smokeEnv = { PATH: fakeBin, AGENTRUN_RESOURCE_BIN_PATH: path.join(context.root, "tools") };
|
||||
const imageSmoke = await smokeImageWorkReadyCapabilities(smokeEnv);
|
||||
assert.equal(((imageSmoke.smoke as { ok?: unknown }).ok), true);
|
||||
assert.equal(imageSmoke.valuesPrinted, false);
|
||||
const bundleSmoke = await smokeBundledWorkReadyCapabilities(smokeEnv);
|
||||
assert.equal(((bundleSmoke.smoke as { ok?: unknown }).ok), true);
|
||||
assert.equal(bundleSmoke.valuesPrinted, false);
|
||||
assert.equal(JSON.stringify({ imageSmoke, bundleSmoke }).includes("GH_TOKEN"), false);
|
||||
const imageRef = validateAipodImageRef({ kind: "env-image-dockerfile", repoUrl: "git@github.com:pikasTech/agentrun.git", commitId: "59272f8edb4e23d805b7b9da0050ec40cfc0233d", dockerfilePath: "deploy/container/Containerfile" });
|
||||
assert.equal(imageRefSourceIdentity(imageRef).length, 20);
|
||||
assert.equal((imageRefSourceSummary(imageRef).valuesPrinted), false);
|
||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
||||
|
||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled"] };
|
||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
|
||||
};
|
||||
|
||||
async function createFakeToolBin(dir: string): Promise<string> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const tool of ["bun", "node", "npm", "git", "ssh", "gh", "rg", "curl", "kubectl"]) {
|
||||
const file = path.join(dir, tool);
|
||||
await writeFile(file, `#!/bin/sh\necho ${tool}-selftest-version\n`, "utf8");
|
||||
await chmod(file, 0o755);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function installedApkPackages(containerfile: string): Set<string> {
|
||||
const packages = new Set<string>();
|
||||
const normalized = containerfile.replace(/\\\s*\r?\n\s*/gu, " ");
|
||||
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -31,14 +31,19 @@ type SelfTestRunContext = Pick<SelfTestContext, "workspace" | "codexHome"> & Par
|
||||
|
||||
export async function createSelfTestContext(root: string): Promise<SelfTestContext> {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-"));
|
||||
const previousSelftestWorkReadyBinPath = process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
||||
const codexHome = path.join(tmp, "codex-home");
|
||||
const deepseekHome = path.join(tmp, "deepseek-home");
|
||||
const minimaxM3Home = path.join(tmp, "minimax-m3-home");
|
||||
const workReadyBin = path.join(tmp, "work-ready-bin");
|
||||
const workspace = path.join(tmp, "workspace");
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
await mkdir(deepseekHome, { recursive: true });
|
||||
await mkdir(minimaxM3Home, { recursive: true });
|
||||
await mkdir(workReadyBin, { recursive: true });
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await writeFakeWorkReadyTools(workReadyBin);
|
||||
process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH = workReadyBin;
|
||||
await writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ token: "test-token-material" }));
|
||||
await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n");
|
||||
await writeFile(path.join(deepseekHome, "auth.json"), JSON.stringify({ token: "test-token-material-deepseek" }));
|
||||
@@ -58,10 +63,22 @@ export async function createSelfTestContext(root: string): Promise<SelfTestConte
|
||||
fakeCodexPath,
|
||||
fakeCodexCommand: process.env.AGENTRUN_SELFTEST_CODEX_COMMAND ?? defaultFakeCommand(),
|
||||
fakeCodexArgs: process.env.AGENTRUN_SELFTEST_CODEX_ARGS ? JSON.parse(process.env.AGENTRUN_SELFTEST_CODEX_ARGS) as string[] : defaultFakeArgs(fakeCodexPath),
|
||||
cleanup: () => rm(tmp, { recursive: true, force: true }),
|
||||
cleanup: async () => {
|
||||
if (previousSelftestWorkReadyBinPath === undefined) delete process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH;
|
||||
else process.env.AGENTRUN_SELFTEST_WORK_READY_BIN_PATH = previousSelftestWorkReadyBinPath;
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeFakeWorkReadyTools(dir: string): Promise<void> {
|
||||
for (const tool of ["bun", "node", "npm", "git", "ssh", "gh", "rg", "curl", "kubectl"]) {
|
||||
const file = path.join(dir, tool);
|
||||
await writeFile(file, `#!/bin/sh\necho ${tool}-selftest-version\n`, "utf8");
|
||||
await chmod(file, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRunWithCommand(client: ManagerClient, context: SelfTestRunContext, prompt: string, idempotencyKey: string, timeoutMs: number): Promise<{ runId: string; commandId: string }> {
|
||||
const backendProfile = context.backendProfile ?? "codex";
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
|
||||
Reference in New Issue
Block a user