fix: add aipod imageRef work-ready runner reuse

This commit is contained in:
AgentRun Codex
2026-06-11 01:21:56 +08:00
parent 59272f8edb
commit 5a6e5a4bbd
22 changed files with 598 additions and 32 deletions
+14 -2
View File
@@ -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);
+176
View File
@@ -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);
}
+10
View File
@@ -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;
}
+130
View File
@@ -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" ? ";" : ":";
}